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

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