feat: IoT卡新增绑定设备虚拟号快照字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m28s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m28s
- tb_iot_card 新增 device_virtual_no 字段,存储绑定设备的虚拟号快照 - 列表/详情接口响应新增 device_virtual_no 字段 - 列表接口 is_standalone 改为可选参数(不传返回全部卡) - 移除列表接口 virtual_no 查询参数 - 绑卡/解绑时同步更新 device_virtual_no 快照 - 设备导入时批量写入 device_virtual_no 快照 - 归档 feat-iot-card-device-virtual-no 变更提案 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ type ListStandaloneIotCardRequest struct {
|
||||
SeriesID *uint `json:"series_id" query:"series_id" description:"套餐系列ID"`
|
||||
ICCID string `json:"iccid" query:"iccid" validate:"omitempty,max=20" maxLength:"20" description:"ICCID(模糊查询)"`
|
||||
MSISDN string `json:"msisdn" query:"msisdn" validate:"omitempty,max=20" maxLength:"20" description:"卡接入号(模糊查询)"`
|
||||
VirtualNo string `json:"virtual_no" query:"virtual_no" validate:"omitempty,max=50" maxLength:"50" description:"虚拟号(模糊查询)"`
|
||||
IsStandalone *bool `json:"is_standalone" query:"is_standalone" description:"是否为独立卡(true:仅返回未绑定设备的卡, false:仅返回已绑定设备的卡, 不传:返回全部)"`
|
||||
BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"`
|
||||
PackageID *uint `json:"package_id" query:"package_id" description:"套餐ID"`
|
||||
IsDistributed *bool `json:"is_distributed" query:"is_distributed" description:"是否已分销 (true:已分销, false:未分销)"`
|
||||
@@ -50,6 +50,7 @@ type StandaloneIotCardResponse struct {
|
||||
SeriesName string `json:"series_name" description:"套餐系列名称"`
|
||||
FirstCommissionPaid bool `json:"first_commission_paid" description:"一次性佣金是否已发放"`
|
||||
AccumulatedRecharge int64 `json:"accumulated_recharge" description:"累计充值金额(分)"`
|
||||
DeviceVirtualNo string `json:"device_virtual_no" description:"绑定设备的虚拟号(未绑定时为空字符串)"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ type IotCard struct {
|
||||
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
|
||||
IsStandalone bool `gorm:"column:is_standalone;type:boolean;default:true;not null;comment:是否为独立卡(未绑定设备) 由触发器自动维护" json:"is_standalone"`
|
||||
VirtualNo string `gorm:"column:virtual_no;type:varchar(50);uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> '';comment:虚拟号(可选,非空时全局唯一)" json:"virtual_no,omitempty"`
|
||||
DeviceVirtualNo string `gorm:"column:device_virtual_no;type:varchar(100);not null;default:'';comment:绑定设备的虚拟号快照(未绑定时为空字符串)" json:"device_virtual_no"`
|
||||
LastGatewayReadingMB float64 `gorm:"column:last_gateway_reading_mb;type:float;not null;default:0;comment:上次轮询时网关返回的流量读数(MB)" json:"last_gateway_reading_mb"`
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -115,6 +117,15 @@ func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCard
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 异步快照维护:更新卡上的设备虚拟号,失败不阻断主流程
|
||||
if updateErr := s.iotCardStore.UpdateDeviceVirtualNo(ctx, card.ID, device.VirtualNo); updateErr != nil {
|
||||
logger.GetAppLogger().Warn("更新卡的设备虚拟号快照失败",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.Uint("device_id", device.ID),
|
||||
zap.Error(updateErr),
|
||||
)
|
||||
}
|
||||
|
||||
return &dto.BindCardToDeviceResponse{
|
||||
BindingID: binding.ID,
|
||||
Message: "绑定成功",
|
||||
@@ -142,6 +153,15 @@ func (s *Service) UnbindCard(ctx context.Context, deviceID uint, cardID uint) (*
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 解绑后清空卡的设备虚拟号快照,失败不阻断主流程
|
||||
if updateErr := s.iotCardStore.UpdateDeviceVirtualNo(ctx, cardID, ""); updateErr != nil {
|
||||
logger.GetAppLogger().Warn("清空卡的设备虚拟号快照失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.Error(updateErr),
|
||||
)
|
||||
}
|
||||
|
||||
return &dto.UnbindCardFromDeviceResponse{
|
||||
Message: "解绑成功",
|
||||
}, nil
|
||||
|
||||
@@ -124,8 +124,8 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot
|
||||
if req.MSISDN != "" {
|
||||
filters["msisdn"] = req.MSISDN
|
||||
}
|
||||
if req.VirtualNo != "" {
|
||||
filters["virtual_no"] = req.VirtualNo
|
||||
if req.IsStandalone != nil {
|
||||
filters["is_standalone"] = req.IsStandalone
|
||||
}
|
||||
if req.BatchNo != "" {
|
||||
filters["batch_no"] = req.BatchNo
|
||||
@@ -279,6 +279,7 @@ func (s *Service) toStandaloneResponse(card *model.IotCard, shopMap map[uint]str
|
||||
SeriesID: card.SeriesID,
|
||||
FirstCommissionPaid: card.FirstCommissionPaid,
|
||||
AccumulatedRecharge: card.AccumulatedRecharge,
|
||||
DeviceVirtualNo: card.DeviceVirtualNo,
|
||||
CreatedAt: card.CreatedAt,
|
||||
UpdatedAt: card.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -271,8 +271,7 @@ func (s *IotCardStore) ListStandalone(ctx context.Context, opts *store.QueryOpti
|
||||
func (s *IotCardStore) listStandaloneTwoPhase(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.IotCard, int64, error) {
|
||||
var total int64
|
||||
|
||||
query := s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("is_standalone = true")
|
||||
query := s.db.WithContext(ctx).Model(&model.IotCard{})
|
||||
// 应用数据权限过滤
|
||||
query = middleware.ApplyShopFilter(ctx, query)
|
||||
query = s.applyStandaloneFilters(ctx, query, filters)
|
||||
@@ -333,8 +332,7 @@ func (s *IotCardStore) listStandaloneDefault(ctx context.Context, opts *store.Qu
|
||||
var cards []*model.IotCard
|
||||
var total int64
|
||||
|
||||
query := s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("is_standalone = true")
|
||||
query := s.db.WithContext(ctx).Model(&model.IotCard{})
|
||||
// 应用数据权限过滤
|
||||
query = middleware.ApplyShopFilter(ctx, query)
|
||||
query = s.applyStandaloneFilters(ctx, query, filters)
|
||||
@@ -395,7 +393,7 @@ func (s *IotCardStore) listStandaloneParallel(ctx context.Context, opts *store.Q
|
||||
defer wg.Done()
|
||||
|
||||
q := s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("is_standalone = true AND deleted_at IS NULL AND shop_id = ?", sid)
|
||||
Where("deleted_at IS NULL AND shop_id = ?", sid)
|
||||
q = s.applyStandaloneFilters(ctx, q, filters)
|
||||
|
||||
var cards []*model.IotCard
|
||||
@@ -410,7 +408,7 @@ func (s *IotCardStore) listStandaloneParallel(ctx context.Context, opts *store.Q
|
||||
var count int64
|
||||
if !hasCachedTotal {
|
||||
countQ := s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("is_standalone = true AND deleted_at IS NULL AND shop_id = ?", sid)
|
||||
Where("deleted_at IS NULL AND shop_id = ?", sid)
|
||||
countQ = s.applyStandaloneFilters(ctx, countQ, filters)
|
||||
if err := countQ.Count(&count).Error; err != nil {
|
||||
results[idx] = shopResult{err: err}
|
||||
@@ -609,9 +607,14 @@ func (s *IotCardStore) listStandaloneParallelTwoPhase(ctx context.Context, opts
|
||||
}
|
||||
|
||||
// applyStandaloneFilters 应用独立卡列表的通用过滤条件
|
||||
// 注意:不包含 is_standalone、shop_id、deleted_at 条件(由调用方控制)
|
||||
// 注意:不包含 shop_id、deleted_at 条件(由调用方控制)
|
||||
// 也不包含 subordinate_shop_ids(仅用于路由选择,不作为查询条件)
|
||||
// is_standalone 仅在 filters["is_standalone"] 非 nil 时拼接(不传则不过滤)
|
||||
func (s *IotCardStore) applyStandaloneFilters(ctx context.Context, query *gorm.DB, filters map[string]any) *gorm.DB {
|
||||
// is_standalone 为可选过滤条件,仅在明确传入时应用
|
||||
if isStandalone, ok := filters["is_standalone"].(*bool); ok && isStandalone != nil {
|
||||
query = query.Where("is_standalone = ?", *isStandalone)
|
||||
}
|
||||
// 子查询无需数据权限过滤(在不同表上执行)
|
||||
|
||||
if status, ok := filters["status"].(int); ok && status > 0 {
|
||||
@@ -823,6 +826,24 @@ func (s *IotCardStore) GetByIDsWithEnterpriseFilter(ctx context.Context, cardIDs
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
// UpdateDeviceVirtualNo 更新卡的设备虚拟号快照
|
||||
// 绑定设备时写入 virtual_no,解绑时写入空字符串
|
||||
func (s *IotCardStore) UpdateDeviceVirtualNo(ctx context.Context, cardID uint, deviceVirtualNo string) error {
|
||||
return s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("id = ?", cardID).
|
||||
Update("device_virtual_no", deviceVirtualNo).Error
|
||||
}
|
||||
|
||||
// BatchUpdateDeviceVirtualNo 批量更新卡的设备虚拟号快照(设备导入时使用)
|
||||
func (s *IotCardStore) BatchUpdateDeviceVirtualNo(ctx context.Context, cardIDs []uint, deviceVirtualNo string) error {
|
||||
if len(cardIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("id IN ?", cardIDs).
|
||||
Update("device_virtual_no", deviceVirtualNo).Error
|
||||
}
|
||||
|
||||
// BatchUpdateSeriesID 批量更新卡的套餐系列ID
|
||||
// 用于批量设置或清除卡与套餐系列的关联关系
|
||||
func (s *IotCardStore) BatchUpdateSeriesID(ctx context.Context, cardIDs []uint, seriesID *uint) error {
|
||||
|
||||
@@ -276,6 +276,7 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
txDeviceStore := postgres.NewDeviceStore(tx, nil)
|
||||
txBindingStore := postgres.NewDeviceSimBindingStore(tx, nil)
|
||||
txIotCardStore := postgres.NewIotCardStore(tx, nil)
|
||||
|
||||
device := &model.Device{
|
||||
VirtualNo: row.VirtualNo,
|
||||
@@ -314,6 +315,13 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
}
|
||||
}
|
||||
|
||||
// 批量更新卡的设备虚拟号快照(与绑定记录在同一事务中执行)
|
||||
if len(validCardIDs) > 0 {
|
||||
if err := txIotCardStore.BatchUpdateDeviceVirtualNo(ctx, validCardIDs, device.VirtualNo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
txWalletStore := postgres.NewAssetWalletStore(tx, nil)
|
||||
deviceWallet := &model.AssetWallet{
|
||||
ResourceType: constants.AssetWalletResourceTypeDevice,
|
||||
|
||||
Reference in New Issue
Block a user