feat: IoT卡新增绑定设备虚拟号快照字段
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:
2026-04-09 10:50:46 +08:00
parent cc56e27c01
commit e9df1e7ded
16 changed files with 421 additions and 704 deletions

View File

@@ -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:"更新时间"`
}

View File

@@ -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"`
}

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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 {

View File

@@ -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,