feat: 新增数据库迁移,重命名 device_no 为 virtual_no,新增 iot_card.virtual_no 和 package.virtual_ratio 字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m3s

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-03-14 18:27:28 +08:00
parent b5147d1acb
commit b9c3875c08
77 changed files with 5832 additions and 2393 deletions

View File

@@ -0,0 +1,477 @@
// Package asset 提供统一的资产查询与操作服务
// 资产包含两种类型IoT卡(card)和设备(device)
// 支持资产解析、实时状态查询、网关刷新、套餐查询等功能
package asset
import (
"context"
"sort"
"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
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,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
shopStore *postgres.ShopStore,
redisClient *redis.Client,
iotCardService IotCardRefresher,
) *Service {
return &Service{
db: db,
deviceStore: deviceStore,
iotCardStore: iotCardStore,
packageUsageStore: packageUsageStore,
packageStore: packageStore,
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)
}
// 未找到设备,查 IotCardvirtual_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)
// 查 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)
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
}
}
// 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 中的 carrierTypecard→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: u.ActivatedAt,
ExpiresAt: 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: usage.ActivatedAt,
ExpiresAt: 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"
}
// 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 "未知"
}
}

View File

@@ -2,6 +2,11 @@ package device
import (
"context"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
@@ -10,11 +15,13 @@ import (
"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"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
type Service struct {
db *gorm.DB
redis *redis.Client
deviceStore *postgres.DeviceStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
iotCardStore *postgres.IotCardStore
@@ -28,6 +35,7 @@ type Service struct {
func New(
db *gorm.DB,
rds *redis.Client,
deviceStore *postgres.DeviceStore,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
iotCardStore *postgres.IotCardStore,
@@ -40,6 +48,7 @@ func New(
) *Service {
return &Service{
db: db,
redis: rds,
deviceStore: deviceStore,
deviceSimBindingStore: deviceSimBindingStore,
iotCardStore: iotCardStore,
@@ -69,8 +78,8 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li
}
filters := make(map[string]interface{})
if req.DeviceNo != "" {
filters["device_no"] = req.DeviceNo
if req.VirtualNo != "" {
filters["virtual_no"] = req.VirtualNo
}
if req.DeviceName != "" {
filters["device_name"] = req.DeviceName
@@ -251,9 +260,9 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR
// 平台只能分配 shop_id=NULL 的设备
if isPlatform && device.ShopID != nil {
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
Reason: "平台只能分配库存设备",
DeviceID: device.ID,
VirtualNo: device.VirtualNo,
Reason: "平台只能分配库存设备",
})
continue
}
@@ -261,9 +270,9 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR
// 代理只能分配自己店铺的设备
if !isPlatform && (device.ShopID == nil || *device.ShopID != *operatorShopID) {
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
Reason: "设备不属于当前店铺",
DeviceID: device.ID,
VirtualNo: device.VirtualNo,
Reason: "设备不属于当前店铺",
})
continue
}
@@ -342,9 +351,9 @@ func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesReque
// 验证设备所属店铺是否为直属下级
if device.ShopID == nil {
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
Reason: "设备已在平台库存中",
DeviceID: device.ID,
VirtualNo: device.VirtualNo,
Reason: "设备已在平台库存中",
})
continue
}
@@ -353,9 +362,9 @@ func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesReque
if !isPlatform {
if err := s.validateDirectSubordinate(ctx, operatorShopID, *device.ShopID); err != nil {
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
Reason: "只能回收直属下级店铺的设备",
DeviceID: device.ID,
VirtualNo: device.VirtualNo,
Reason: "只能回收直属下级店铺的设备",
})
continue
}
@@ -528,7 +537,7 @@ func (s *Service) extractDeviceIDs(devices []*model.Device) []uint {
func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string, seriesMap map[uint]string, bindingCounts map[uint]int64) *dto.DeviceResponse {
resp := &dto.DeviceResponse{
ID: device.ID,
DeviceNo: device.DeviceNo,
VirtualNo: device.VirtualNo,
IMEI: device.IMEI,
SN: device.SN,
DeviceName: device.DeviceName,
@@ -592,7 +601,7 @@ func (s *Service) buildAllocationRecords(devices []*model.Device, successDeviceI
AllocationType: constants.AssetAllocationTypeAllocate,
AssetType: constants.AssetTypeDevice,
AssetID: device.ID,
AssetIdentifier: device.DeviceNo,
AssetIdentifier: device.VirtualNo,
ToOwnerType: constants.OwnerTypeShop,
ToOwnerID: toShopID,
OperatorID: operatorID,
@@ -630,7 +639,7 @@ func (s *Service) buildRecallRecords(devices []*model.Device, successDeviceIDs [
AllocationType: constants.AssetAllocationTypeRecall,
AssetType: constants.AssetTypeDevice,
AssetID: device.ID,
AssetIdentifier: device.DeviceNo,
AssetIdentifier: device.VirtualNo,
OperatorID: operatorID,
Remark: remark,
}
@@ -699,9 +708,9 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe
device, exists := deviceMap[deviceID]
if !exists {
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
DeviceID: deviceID,
DeviceNo: "",
Reason: "设备不存在",
DeviceID: deviceID,
VirtualNo: "",
Reason: "设备不存在",
})
continue
}
@@ -721,9 +730,9 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe
}
if !hasSeriesAllocation {
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
DeviceID: deviceID,
DeviceNo: device.DeviceNo,
Reason: "您没有权限分配该套餐系列",
DeviceID: deviceID,
VirtualNo: device.VirtualNo,
Reason: "您没有权限分配该套餐系列",
})
continue
}
@@ -733,9 +742,9 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe
if operatorShopID != nil {
if device.ShopID == nil || *device.ShopID != *operatorShopID {
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
Reason: "无权操作此设备",
DeviceID: device.ID,
VirtualNo: device.VirtualNo,
Reason: "无权操作此设备",
})
continue
}
@@ -765,10 +774,207 @@ func (s *Service) buildDeviceNotFoundFailedItems(deviceIDs []uint) []dto.DeviceS
items := make([]dto.DeviceSeriesBindngFailedItem, len(deviceIDs))
for i, id := range deviceIDs {
items[i] = dto.DeviceSeriesBindngFailedItem{
DeviceID: id,
DeviceNo: "",
Reason: "设备不存在",
DeviceID: id,
VirtualNo: "",
Reason: "设备不存在",
}
}
return items
}
// StopDevice 设备停机
// POST /api/admin/assets/device/:device_id/stop
// 查找设备绑定的所有已实名且已开机的卡,逐一调网关停机
func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSuspendResponse, error) {
log := logger.GetAppLogger()
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "设备不存在")
}
_ = device
// 复机保护期内禁止停机
if s.redis != nil {
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Result()
if exists > 0 {
return nil, errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机")
}
}
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
}
if len(bindings) == 0 {
return &dto.DeviceSuspendResponse{}, nil
}
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, "查询卡信息失败")
}
var successCount, skipCount int
var failedItems []dto.DeviceSuspendFailItem
for _, card := range cards {
if card.RealNameStatus != constants.RealNameStatusVerified || card.NetworkStatus != constants.NetworkStatusOnline {
skipCount++
continue
}
if s.gatewayClient != nil {
if gwErr := s.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); gwErr != nil {
log.Error("设备停机-调网关停机失败",
zap.Uint("device_id", deviceID),
zap.String("iccid", card.ICCID),
zap.Error(gwErr))
failedItems = append(failedItems, dto.DeviceSuspendFailItem{
ICCID: card.ICCID,
Reason: "网关停机失败",
})
continue
}
}
now := time.Now()
if dbErr := s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("id = ?", card.ID).
Updates(map[string]any{
"network_status": constants.NetworkStatusOffline,
"stopped_at": now,
"stop_reason": constants.StopReasonManual,
}).Error; dbErr != nil {
log.Error("设备停机-更新卡状态失败",
zap.Uint("card_id", card.ID),
zap.Error(dbErr))
failedItems = append(failedItems, dto.DeviceSuspendFailItem{
ICCID: card.ICCID,
Reason: "更新卡状态失败",
})
continue
}
successCount++
}
// 成功停机至少一张卡后设置保护期
if successCount > 0 && s.redis != nil {
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"), 1, constants.DeviceProtectPeriodDuration)
s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "start"))
}
return &dto.DeviceSuspendResponse{
SuccessCount: successCount,
FailCount: len(failedItems),
SkipCount: skipCount,
FailedItems: failedItems,
}, nil
}
// StartDevice 设备复机
// POST /api/admin/assets/device/:device_id/start
// 查找设备绑定的所有已实名且已停机的卡,逐一调网关复机
func (s *Service) StartDevice(ctx context.Context, deviceID uint) error {
log := logger.GetAppLogger()
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
return errors.New(errors.CodeNotFound, "设备不存在")
}
_ = device
// 停机保护期内禁止复机
if s.redis != nil {
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Result()
if exists > 0 {
return errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机")
}
}
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
}
if len(bindings) == 0 {
return nil
}
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 errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败")
}
var successCount int
var lastErr error
for _, card := range cards {
if card.RealNameStatus != constants.RealNameStatusVerified || card.NetworkStatus != constants.NetworkStatusOffline {
continue
}
if s.gatewayClient != nil {
if gwErr := s.gatewayClient.StartCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); gwErr != nil {
log.Error("设备复机-调网关复机失败",
zap.Uint("device_id", deviceID),
zap.String("iccid", card.ICCID),
zap.Error(gwErr))
lastErr = gwErr
continue
}
}
now := time.Now()
if dbErr := s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("id = ?", card.ID).
Updates(map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
}).Error; dbErr != nil {
log.Error("设备复机-更新卡状态失败",
zap.Uint("card_id", card.ID),
zap.Error(dbErr))
lastErr = dbErr
continue
}
successCount++
}
// 成功复机至少一张卡后设置保护期
if successCount > 0 && s.redis != nil {
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "start"), 1, constants.DeviceProtectPeriodDuration)
s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"))
}
// 全部失败时返回 error
if successCount == 0 && lastErr != nil {
return errors.Wrap(errors.CodeInternalError, lastErr, "设备复机失败")
}
return nil
}

View File

@@ -139,25 +139,25 @@ func (s *Service) GetByID(ctx context.Context, id uint) (*dto.DeviceImportTaskDe
for _, item := range task.SkippedItems {
resp.SkippedItems = append(resp.SkippedItems, &dto.DeviceImportResultItemDTO{
Line: item.Line,
DeviceNo: item.ICCID,
Reason: item.Reason,
Line: item.Line,
VirtualNo: item.ICCID,
Reason: item.Reason,
})
}
for _, item := range task.FailedItems {
resp.FailedItems = append(resp.FailedItems, &dto.DeviceImportResultItemDTO{
Line: item.Line,
DeviceNo: item.ICCID,
Reason: item.Reason,
Line: item.Line,
VirtualNo: item.ICCID,
Reason: item.Reason,
})
}
for _, item := range task.WarningItems {
resp.WarningItems = append(resp.WarningItems, &dto.DeviceImportResultItemDTO{
Line: item.Line,
DeviceNo: item.ICCID,
Reason: item.Reason,
Line: item.Line,
VirtualNo: item.ICCID,
Reason: item.Reason,
})
}

View File

@@ -59,14 +59,14 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
// 查询所有设备
var devices []model.Device
if err := s.db.WithContext(ctx).Where("device_no IN ?", req.DeviceNos).Find(&devices).Error; err != nil {
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", req.DeviceNos).Find(&devices).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
}
deviceMap := make(map[string]*model.Device)
deviceIDs := make([]uint, 0, len(devices))
for i := range devices {
deviceMap[devices[i].DeviceNo] = &devices[i]
deviceMap[devices[i].VirtualNo] = &devices[i]
deviceIDs = append(deviceIDs, devices[i].ID)
}
@@ -90,8 +90,8 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
device, exists := deviceMap[deviceNo]
if !exists {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
DeviceNo: deviceNo,
Reason: "设备不存在",
VirtualNo: deviceNo,
Reason: "设备不存在",
})
continue
}
@@ -99,8 +99,8 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
// 验证设备状态(必须是"已分销"状态)
if device.Status != 2 {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
DeviceNo: deviceNo,
Reason: "设备状态不正确,必须是已分销状态",
VirtualNo: deviceNo,
Reason: "设备状态不正确,必须是已分销状态",
})
continue
}
@@ -109,8 +109,8 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
if userType == constants.UserTypeAgent {
if device.ShopID == nil || *device.ShopID != currentShopID {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
DeviceNo: deviceNo,
Reason: "无权操作此设备",
VirtualNo: deviceNo,
Reason: "无权操作此设备",
})
continue
}
@@ -119,8 +119,8 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
// 检查是否已授权
if existingAuths[device.ID] {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
DeviceNo: deviceNo,
Reason: "设备已授权给此企业",
VirtualNo: deviceNo,
Reason: "设备已授权给此企业",
})
continue
}
@@ -199,7 +199,7 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
for _, device := range devicesToAllocate {
resp.AuthorizedDevices = append(resp.AuthorizedDevices, dto.AuthorizedDeviceItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
VirtualNo: device.VirtualNo,
CardCount: deviceCardCount[device.ID],
})
}
@@ -232,14 +232,14 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
// 查询设备
var devices []model.Device
if err := s.db.WithContext(ctx).Where("device_no IN ?", req.DeviceNos).Find(&devices).Error; err != nil {
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", req.DeviceNos).Find(&devices).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
}
deviceMap := make(map[string]*model.Device)
deviceIDs := make([]uint, 0, len(devices))
for i := range devices {
deviceMap[devices[i].DeviceNo] = &devices[i]
deviceMap[devices[i].VirtualNo] = &devices[i]
deviceIDs = append(deviceIDs, devices[i].ID)
}
@@ -258,16 +258,16 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
device, exists := deviceMap[deviceNo]
if !exists {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
DeviceNo: deviceNo,
Reason: "设备不存在",
VirtualNo: deviceNo,
Reason: "设备不存在",
})
continue
}
if !existingAuths[device.ID] {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
DeviceNo: deviceNo,
Reason: "设备未授权给此企业",
VirtualNo: deviceNo,
Reason: "设备未授权给此企业",
})
continue
}
@@ -276,8 +276,8 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
auth, err := s.enterpriseDeviceAuthStore.GetByDeviceID(ctx, device.ID)
if err != nil || auth.EnterpriseID != enterpriseID {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
DeviceNo: deviceNo,
Reason: "授权记录不存在",
VirtualNo: deviceNo,
Reason: "授权记录不存在",
})
continue
}
@@ -352,8 +352,8 @@ func (s *Service) ListDevices(ctx context.Context, enterpriseID uint, req *dto.E
// 查询设备信息
var devices []model.Device
query := s.db.WithContext(ctx).Where("id IN ?", deviceIDs)
if req.DeviceNo != "" {
query = query.Where("device_no LIKE ?", "%"+req.DeviceNo+"%")
if req.VirtualNo != "" {
query = query.Where("virtual_no LIKE ?", "%"+req.VirtualNo+"%")
}
if err := query.Find(&devices).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
@@ -378,7 +378,7 @@ func (s *Service) ListDevices(ctx context.Context, enterpriseID uint, req *dto.E
auth := authMap[device.ID]
items = append(items, dto.EnterpriseDeviceItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
VirtualNo: device.VirtualNo,
DeviceName: device.DeviceName,
DeviceModel: device.DeviceModel,
CardCount: cardCountMap[device.ID],
@@ -427,8 +427,8 @@ func (s *Service) ListDevicesForEnterprise(ctx context.Context, req *dto.Enterpr
var devices []model.Device
query := s.db.WithContext(ctx).Where("id IN ?", deviceIDs)
if req.DeviceNo != "" {
query = query.Where("device_no LIKE ?", "%"+req.DeviceNo+"%")
if req.VirtualNo != "" {
query = query.Where("virtual_no LIKE ?", "%"+req.VirtualNo+"%")
}
if err := query.Find(&devices).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
@@ -451,7 +451,7 @@ func (s *Service) ListDevicesForEnterprise(ctx context.Context, req *dto.Enterpr
auth := authMap[device.ID]
items = append(items, dto.EnterpriseDeviceItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
VirtualNo: device.VirtualNo,
DeviceName: device.DeviceName,
DeviceModel: device.DeviceModel,
CardCount: cardCountMap[device.ID],
@@ -531,7 +531,7 @@ func (s *Service) GetDeviceDetail(ctx context.Context, deviceID uint) (*dto.Ente
return &dto.EnterpriseDeviceDetailResp{
Device: dto.EnterpriseDeviceInfo{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
VirtualNo: device.VirtualNo,
DeviceName: device.DeviceName,
DeviceModel: device.DeviceModel,
DeviceType: device.DeviceType,

View File

@@ -2,6 +2,7 @@ package iot_card
import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
@@ -795,20 +796,9 @@ func (s *Service) buildCardNotFoundFailedItems(iccids []string) []dto.CardSeries
return items
}
// SyncCardStatusFromGateway 从 Gateway 同步卡状态(示例方法)
func (s *Service) SyncCardStatusFromGateway(ctx context.Context, iccid string) error {
if s.gatewayClient == nil {
return errors.New(errors.CodeGatewayError, "Gateway 客户端未配置")
}
resp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
CardNo: iccid,
})
if err != nil {
s.logger.Error("查询卡状态失败", zap.String("iccid", iccid), zap.Error(err))
return errors.Wrap(errors.CodeGatewayError, err, "查询卡状态失败")
}
// RefreshCardDataFromGateway 从 Gateway 完整同步卡数据
// 调用网关查询网络状态、实名状态、本月流量,并写回数据库
func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string) error {
card, err := s.iotCardStore.GetByICCID(ctx, iccid)
if err != nil {
if err == gorm.ErrRecordNotFound {
@@ -817,40 +807,73 @@ func (s *Service) SyncCardStatusFromGateway(ctx context.Context, iccid string) e
return err
}
var newStatus int
switch resp.CardStatus {
case "准备":
newStatus = constants.IotCardStatusInStock
case "正常":
newStatus = constants.IotCardStatusDistributed
case "停机":
newStatus = constants.IotCardStatusSuspended
default:
s.logger.Warn("未知的卡状态", zap.String("cardStatus", resp.CardStatus))
return nil
syncTime := time.Now()
updates := map[string]any{
"last_sync_time": syncTime,
}
if card.Status != newStatus {
oldStatus := card.Status
card.Status = newStatus
if err := s.iotCardStore.Update(ctx, card); err != nil {
return err
if s.gatewayClient != nil {
// 1. 查询网络状态(卡的开/停机状态)
statusResp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
CardNo: iccid,
})
if err != nil {
s.logger.Warn("刷新卡数据:查询网络状态失败", zap.String("iccid", iccid), zap.Error(err))
} else {
networkStatus := parseNetworkStatus(statusResp.CardStatus)
updates["network_status"] = networkStatus
}
s.logger.Info("同步卡状态成功",
zap.String("iccid", iccid),
zap.Int("oldStatus", oldStatus),
zap.Int("newStatus", newStatus),
)
// 通知轮询调度器状态变化
if s.pollingCallback != nil {
s.pollingCallback.OnCardStatusChanged(ctx, card.ID)
// 2. 查询实名状态
realnameResp, err := s.gatewayClient.QueryRealnameStatus(ctx, &gateway.CardStatusReq{
CardNo: iccid,
})
if err != nil {
s.logger.Warn("刷新卡数据:查询实名状态失败", zap.String("iccid", iccid), zap.Error(err))
} else {
realNameStatus := parseGatewayRealnameStatus(realnameResp.RealStatus)
updates["real_name_status"] = realNameStatus
}
// 3. 查询本月流量用量
flowResp, err := s.gatewayClient.QueryFlow(ctx, &gateway.FlowQueryReq{
CardNo: iccid,
})
if err != nil {
s.logger.Warn("刷新卡数据:查询流量失败", zap.String("iccid", iccid), zap.Error(err))
} else {
updates["current_month_usage_mb"] = flowResp.Used
}
}
if err := s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("id = ?", card.ID).
Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新卡数据失败")
}
s.logger.Info("刷新卡数据成功", zap.String("iccid", iccid), zap.Uint("card_id", card.ID))
return nil
}
// parseNetworkStatus 将网关返回的卡状态字符串转换为 network_status 数值
// 停机→0其他准备/正常→1
func parseNetworkStatus(cardStatus string) int {
if cardStatus == "停机" {
return 0
}
return 1
}
// parseGatewayRealnameStatus 将网关返回的实名状态布尔值转换为 real_name_status 数值
// true=已实名(2)false=未实名(0)
func parseGatewayRealnameStatus(realStatus bool) int {
if realStatus {
return 2
}
return 0
}
// UpdatePollingStatus 更新卡的轮询状态
// 启用或禁用卡的轮询功能
func (s *Service) UpdatePollingStatus(ctx context.Context, cardID uint, enablePolling bool) error {

View File

@@ -8,22 +8,25 @@ import (
"go.uber.org/zap"
"gorm.io/gorm"
stderrors "errors"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
"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"
)
// StopResumeService 停复机服务
// 任务 24.2: 处理 IoT 卡的自动停机复机逻辑
// 处理 IoT 卡的自动停机、复机和手动停复机逻辑
type StopResumeService struct {
db *gorm.DB
redis *redis.Client
iotCardStore *postgres.IotCardStore
gatewayClient *gateway.Client
logger *zap.Logger
db *gorm.DB
redis *redis.Client
iotCardStore *postgres.IotCardStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
gatewayClient *gateway.Client
logger *zap.Logger
// 重试配置
maxRetries int
retryInterval time.Duration
}
@@ -33,17 +36,19 @@ func NewStopResumeService(
db *gorm.DB,
redis *redis.Client,
iotCardStore *postgres.IotCardStore,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
gatewayClient *gateway.Client,
logger *zap.Logger,
) *StopResumeService {
return &StopResumeService{
db: db,
redis: redis,
iotCardStore: iotCardStore,
gatewayClient: gatewayClient,
logger: logger,
maxRetries: 3, // 默认最多重试 3 次
retryInterval: 2 * time.Second, // 默认重试间隔 2 秒
db: db,
redis: redis,
iotCardStore: iotCardStore,
deviceSimBindingStore: deviceSimBindingStore,
gatewayClient: gatewayClient,
logger: logger,
maxRetries: 3,
retryInterval: 2 * time.Second,
}
}
@@ -233,3 +238,75 @@ func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model
return lastErr
}
// ManualStopCard 手动停机单张卡通过ICCID
func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) error {
card, err := s.iotCardStore.GetByICCID(ctx, iccid)
if err != nil {
return errors.New(errors.CodeNotFound, "卡不存在")
}
if card.RealNameStatus != constants.RealNameStatusVerified {
return errors.New(errors.CodeForbidden, "卡未实名,无法操作")
}
// 检查绑定设备是否在复机保护期
if s.deviceSimBindingStore != nil && s.redis != nil {
binding, bindErr := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
if bindErr == nil && binding != nil {
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(binding.DeviceID, "start")).Result()
if exists > 0 {
return errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机")
}
} else if bindErr != nil && !stderrors.Is(bindErr, gorm.ErrRecordNotFound) {
return errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败")
}
}
if err := s.stopCardWithRetry(ctx, card); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "调网关停机失败")
}
now := time.Now()
return s.db.WithContext(ctx).Model(card).Updates(map[string]any{
"network_status": constants.NetworkStatusOffline,
"stopped_at": now,
"stop_reason": constants.StopReasonManual,
}).Error
}
// ManualStartCard 手动复机单张卡通过ICCID
func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) error {
card, err := s.iotCardStore.GetByICCID(ctx, iccid)
if err != nil {
return errors.New(errors.CodeNotFound, "卡不存在")
}
if card.RealNameStatus != constants.RealNameStatusVerified {
return errors.New(errors.CodeForbidden, "卡未实名,无法操作")
}
// 检查绑定设备是否在停机保护期
if s.deviceSimBindingStore != nil && s.redis != nil {
binding, bindErr := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
if bindErr == nil && binding != nil {
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(binding.DeviceID, "stop")).Result()
if exists > 0 {
return errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机")
}
} else if bindErr != nil && !stderrors.Is(bindErr, gorm.ErrRecordNotFound) {
return errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败")
}
}
if err := s.resumeCardWithRetry(ctx, card); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "调网关复机失败")
}
now := time.Now()
return s.db.WithContext(ctx).Model(card).Updates(map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
}).Error
}

View File

@@ -214,7 +214,6 @@ func (s *Service) CreateWithdrawalRequest(ctx context.Context, req *dto.CreateMy
return nil
})
if err != nil {
return nil, err
}

View File

@@ -126,9 +126,9 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
if req.EnableRealnameActivation != nil {
pkg.EnableRealnameActivation = *req.EnableRealnameActivation
} else {
// 默认启用实名激活
pkg.EnableRealnameActivation = true
}
pkg.VirtualRatio = calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB)
pkg.Creator = currentUserID
if err := s.packageStore.Create(ctx, pkg); err != nil {
@@ -250,6 +250,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
}
}
pkg.VirtualRatio = calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB)
pkg.Updater = currentUserID
if err := s.packageStore.Update(ctx, pkg); err != nil {
@@ -673,3 +674,12 @@ func formatAmount(amountFen int64) string {
}
return fmt.Sprintf("%.2f元/张", yuan)
}
// calculateVirtualRatio 计算虚流量比例
// enable_virtual_data=true 且 virtual_data_mb>0 时 = real_data_mb/virtual_data_mb否则 = 1.0
func calculateVirtualRatio(enableVirtualData bool, realDataMB, virtualDataMB int64) float64 {
if enableVirtualData && virtualDataMB > 0 {
return float64(realDataMB) / float64(virtualDataMB)
}
return 1.0
}

View File

@@ -347,7 +347,7 @@ func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, re
ShopID: shopID,
CommissionSource: req.CommissionSource,
ICCID: req.ICCID,
DeviceNo: req.DeviceNo,
DeviceNo: req.VirtualNo,
OrderNo: req.OrderNo,
}
@@ -367,7 +367,7 @@ func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, re
StatusName: getCommissionStatusName(r.Status),
OrderID: r.OrderID,
OrderNo: "",
DeviceNo: "",
VirtualNo: "",
ICCID: "",
OrderCreatedAt: "",
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),