feat: 新增数据库迁移,重命名 device_no 为 virtual_no,新增 iot_card.virtual_no 和 package.virtual_ratio 字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m3s
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:
@@ -180,7 +180,7 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
allICCIDs := make([]string, 0)
|
||||
|
||||
for _, row := range batch {
|
||||
deviceNos = append(deviceNos, row.DeviceNo)
|
||||
deviceNos = append(deviceNos, row.VirtualNo)
|
||||
allICCIDs = append(allICCIDs, row.ICCIDs...)
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
for _, row := range batch {
|
||||
result.failedItems = append(result.failedItems, model.ImportResultItem{
|
||||
Line: row.Line,
|
||||
ICCID: row.DeviceNo,
|
||||
ICCID: row.VirtualNo,
|
||||
Reason: "数据库查询失败",
|
||||
})
|
||||
result.failCount++
|
||||
@@ -218,10 +218,10 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
}
|
||||
|
||||
for _, row := range batch {
|
||||
if existingDevices[row.DeviceNo] {
|
||||
if existingDevices[row.VirtualNo] {
|
||||
result.skippedItems = append(result.skippedItems, model.ImportResultItem{
|
||||
Line: row.Line,
|
||||
ICCID: row.DeviceNo,
|
||||
ICCID: row.VirtualNo,
|
||||
Reason: "设备号已存在",
|
||||
})
|
||||
result.skipCount++
|
||||
@@ -251,7 +251,7 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
if len(row.ICCIDs) > 0 && len(cardIssues) > 0 {
|
||||
result.failedItems = append(result.failedItems, model.ImportResultItem{
|
||||
Line: row.Line,
|
||||
ICCID: row.DeviceNo,
|
||||
ICCID: row.VirtualNo,
|
||||
Reason: "卡验证失败: " + strings.Join(cardIssues, ", "),
|
||||
})
|
||||
result.failCount++
|
||||
@@ -263,7 +263,7 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
txBindingStore := postgres.NewDeviceSimBindingStore(tx, nil)
|
||||
|
||||
device := &model.Device{
|
||||
DeviceNo: row.DeviceNo,
|
||||
VirtualNo: row.VirtualNo,
|
||||
DeviceName: row.DeviceName,
|
||||
DeviceModel: row.DeviceModel,
|
||||
DeviceType: row.DeviceType,
|
||||
@@ -298,12 +298,12 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
|
||||
if err != nil {
|
||||
h.logger.Error("创建设备失败",
|
||||
zap.String("device_no", row.DeviceNo),
|
||||
zap.String("virtual_no", row.VirtualNo),
|
||||
zap.Error(err),
|
||||
)
|
||||
result.failedItems = append(result.failedItems, model.ImportResultItem{
|
||||
Line: row.Line,
|
||||
ICCID: row.DeviceNo,
|
||||
ICCID: row.VirtualNo,
|
||||
Reason: "数据库写入失败: " + err.Error(),
|
||||
})
|
||||
result.failCount++
|
||||
@@ -320,4 +320,4 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
}
|
||||
}
|
||||
|
||||
var ErrMissingDeviceNoColumn = stderrors.New("CSV 缺少 device_no 列")
|
||||
var ErrMissingDeviceNoColumn = stderrors.New("CSV 缺少 virtual_no 列")
|
||||
|
||||
@@ -167,8 +167,9 @@ func (h *IotCardImportHandler) downloadAndParse(ctx context.Context, task *model
|
||||
cards := make(model.CardListJSON, 0, len(parseResult.Cards))
|
||||
for _, card := range parseResult.Cards {
|
||||
cards = append(cards, model.CardItem{
|
||||
ICCID: card.ICCID,
|
||||
MSISDN: card.MSISDN,
|
||||
ICCID: card.ICCID,
|
||||
MSISDN: card.MSISDN,
|
||||
VirtualNo: card.VirtualNo,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -210,15 +211,16 @@ func (h *IotCardImportHandler) getCardsFromTask(task *model.IotCardImportTask) [
|
||||
|
||||
func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.IotCardImportTask, batch []model.CardItem, startLine int, result *importResult) {
|
||||
type cardMeta struct {
|
||||
line int
|
||||
msisdn string
|
||||
line int
|
||||
msisdn string
|
||||
virtualNo string
|
||||
}
|
||||
validCards := make([]model.CardItem, 0)
|
||||
cardMetaMap := make(map[string]cardMeta)
|
||||
|
||||
for i, card := range batch {
|
||||
line := startLine + i
|
||||
cardMetaMap[card.ICCID] = cardMeta{line: line, msisdn: card.MSISDN}
|
||||
cardMetaMap[card.ICCID] = cardMeta{line: line, msisdn: card.MSISDN, virtualNo: card.VirtualNo}
|
||||
|
||||
validationResult := validator.ValidateICCID(card.ICCID, task.CarrierType)
|
||||
if !validationResult.Valid {
|
||||
@@ -282,12 +284,56 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot
|
||||
return
|
||||
}
|
||||
|
||||
iotCards := make([]*model.IotCard, 0, len(newCards))
|
||||
now := time.Now()
|
||||
// 批量检查 virtual_no 唯一性
|
||||
virtualNos := make([]string, 0)
|
||||
for _, card := range newCards {
|
||||
if card.VirtualNo != "" {
|
||||
virtualNos = append(virtualNos, card.VirtualNo)
|
||||
}
|
||||
}
|
||||
existingVirtualNos := make(map[string]bool)
|
||||
if len(virtualNos) > 0 {
|
||||
existingVirtualNos, err = h.iotCardStore.ExistsByVirtualNoBatch(ctx, virtualNos)
|
||||
if err != nil {
|
||||
h.logger.Error("批量检查 virtual_no 是否存在失败",
|
||||
zap.Error(err),
|
||||
zap.Int("batch_size", len(virtualNos)),
|
||||
)
|
||||
}
|
||||
}
|
||||
// 批内去重:记录本批次已分配的 virtual_no
|
||||
batchUsedVirtualNos := make(map[string]bool)
|
||||
|
||||
finalCards := make([]model.CardItem, 0, len(newCards))
|
||||
for _, card := range newCards {
|
||||
meta := cardMetaMap[card.ICCID]
|
||||
if card.VirtualNo != "" {
|
||||
if existingVirtualNos[card.VirtualNo] || batchUsedVirtualNos[card.VirtualNo] {
|
||||
result.failedItems = append(result.failedItems, model.ImportResultItem{
|
||||
Line: meta.line,
|
||||
ICCID: card.ICCID,
|
||||
MSISDN: meta.msisdn,
|
||||
Reason: "virtual_no 已被占用: " + card.VirtualNo,
|
||||
})
|
||||
result.failCount++
|
||||
continue
|
||||
}
|
||||
batchUsedVirtualNos[card.VirtualNo] = true
|
||||
}
|
||||
finalCards = append(finalCards, card)
|
||||
}
|
||||
|
||||
if len(finalCards) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
iotCards := make([]*model.IotCard, 0, len(finalCards))
|
||||
now := time.Now()
|
||||
for _, card := range finalCards {
|
||||
iotCard := &model.IotCard{
|
||||
ICCID: card.ICCID,
|
||||
MSISDN: card.MSISDN,
|
||||
VirtualNo: card.VirtualNo,
|
||||
CarrierID: task.CarrierID,
|
||||
BatchNo: task.BatchNo,
|
||||
Status: constants.IotCardStatusInStock,
|
||||
@@ -308,7 +354,7 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot
|
||||
zap.Error(err),
|
||||
zap.Int("batch_size", len(iotCards)),
|
||||
)
|
||||
for _, card := range newCards {
|
||||
for _, card := range finalCards {
|
||||
meta := cardMetaMap[card.ICCID]
|
||||
result.failedItems = append(result.failedItems, model.ImportResultItem{
|
||||
Line: meta.line,
|
||||
@@ -321,9 +367,8 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot
|
||||
return
|
||||
}
|
||||
|
||||
result.successCount += len(newCards)
|
||||
result.successCount += len(finalCards)
|
||||
|
||||
// 通知轮询系统:批量卡已创建
|
||||
if h.pollingCallback != nil {
|
||||
h.pollingCallback.OnBatchCardsCreated(ctx, iotCards)
|
||||
}
|
||||
|
||||
@@ -757,6 +757,8 @@ func (h *PollingHandler) requeueCard(ctx context.Context, cardID uint, taskType
|
||||
intervalSeconds = 600 // 默认 10 分钟
|
||||
case constants.TaskTypePollingPackage:
|
||||
intervalSeconds = 600 // 默认 10 分钟
|
||||
case constants.TaskTypePollingProtect:
|
||||
intervalSeconds = 300 // 默认 5 分钟
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -773,6 +775,8 @@ func (h *PollingHandler) requeueCard(ctx context.Context, cardID uint, taskType
|
||||
queueKey = constants.RedisPollingQueueCarddataKey()
|
||||
case constants.TaskTypePollingPackage:
|
||||
queueKey = constants.RedisPollingQueuePackageKey()
|
||||
case constants.TaskTypePollingProtect:
|
||||
queueKey = constants.RedisPollingQueueProtectKey()
|
||||
}
|
||||
|
||||
// 添加到队列
|
||||
@@ -943,6 +947,103 @@ func (h *PollingHandler) getCardWithCache(ctx context.Context, cardID uint) (*mo
|
||||
return card, nil
|
||||
}
|
||||
|
||||
// HandleProtectConsistencyCheck 保护期一致性检查
|
||||
// 检查绑定设备(is_standalone=false)且已实名(real_name_status=2)的卡
|
||||
// stop 保护期 + 开机 → 调网关停机;start 保护期 + 停机 → 调网关复机
|
||||
func (h *PollingHandler) HandleProtectConsistencyCheck(ctx context.Context, t *asynq.Task) error {
|
||||
var payload PollingTaskPayload
|
||||
if err := sonic.Unmarshal(t.Payload(), &payload); err != nil {
|
||||
h.logger.Error("解析保护期检查任务载荷失败", zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
cardID, err := strconv.ParseUint(payload.CardID, 10, 64)
|
||||
if err != nil {
|
||||
h.logger.Error("解析卡ID失败", zap.String("card_id", payload.CardID), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 查询卡信息
|
||||
var card model.IotCard
|
||||
if err := h.db.WithContext(ctx).Where("id = ?", cardID).First(&card).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil
|
||||
}
|
||||
h.logger.Error("查询卡信息失败", zap.Uint64("card_id", cardID), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 未绑设备(独立卡),跳过
|
||||
if card.IsStandalone {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 未实名,跳过
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 查绑定设备
|
||||
binding, err := h.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
|
||||
if err != nil || binding == nil {
|
||||
return nil
|
||||
}
|
||||
deviceID := binding.DeviceID
|
||||
|
||||
// 检查 stop 保护期:设备处于停机保护期,但卡是开机状态 → 需要停机
|
||||
stopProtect := h.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Val() > 0
|
||||
if stopProtect && card.NetworkStatus == constants.NetworkStatusOnline {
|
||||
h.logger.Info("保护期一致性检查:停机保护期内发现开机卡,执行停机",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Uint("device_id", deviceID))
|
||||
|
||||
if h.gatewayClient != nil {
|
||||
if err := h.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); err != nil {
|
||||
h.logger.Error("保护期一致性停机失败",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
h.db.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
|
||||
"network_status": constants.NetworkStatusOffline,
|
||||
"stopped_at": time.Now(),
|
||||
"stop_reason": "保护期一致性检查自动停机",
|
||||
})
|
||||
h.updateCardCache(ctx, card.ID, map[string]any{"network_status": constants.NetworkStatusOffline})
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查 start 保护期:设备处于复机保护期,但卡是停机状态 → 需要复机
|
||||
startProtect := h.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Val() > 0
|
||||
if startProtect && card.NetworkStatus == constants.NetworkStatusOffline {
|
||||
h.logger.Info("保护期一致性检查:复机保护期内发现停机卡,执行复机",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Uint("device_id", deviceID))
|
||||
|
||||
if h.gatewayClient != nil {
|
||||
if err := h.gatewayClient.StartCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); err != nil {
|
||||
h.logger.Error("保护期一致性复机失败",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
h.db.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"resumed_at": time.Now(),
|
||||
"stop_reason": "",
|
||||
})
|
||||
h.updateCardCache(ctx, card.ID, map[string]any{"network_status": constants.NetworkStatusOnline})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// triggerFirstRealnameActivation 任务 21.3-21.4: 首次实名后触发套餐激活
|
||||
func (h *PollingHandler) triggerFirstRealnameActivation(ctx context.Context, cardID uint) {
|
||||
// 任务 21.3: 查询该卡是否有待激活套餐
|
||||
|
||||
Reference in New Issue
Block a user