package iot_card import ( "context" stderrors "errors" "strings" "time" "github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" "github.com/break/junhong_cmp_fiber/internal/store" "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/middleware" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" ) const cardTrafficSyncLockTTL = 10 * time.Minute // PollingCallback 轮询回调接口 // 用于在卡生命周期事件发生时通知轮询调度器 type PollingCallback interface { // OnCardCreated 单卡创建时的回调 OnCardCreated(ctx context.Context, card *model.IotCard) // OnCardStatusChanged 卡状态变化时的回调 OnCardStatusChanged(ctx context.Context, cardID uint) // OnCardDeleted 卡删除时的回调 OnCardDeleted(ctx context.Context, cardID uint) // OnCardEnabled 卡启用轮询时的回调 OnCardEnabled(ctx context.Context, cardID uint) // OnCardDisabled 卡禁用轮询时的回调 OnCardDisabled(ctx context.Context, cardID uint) } // DataDeductor 流量扣减回调接口 // 用于在手动刷新流量后触发套餐扣减,避免循环依赖 type DataDeductor interface { // DeductDataUsage 按优先级扣减套餐流量 DeductDataUsage(ctx context.Context, carrierType string, carrierID uint, usageMB float64) error } // RealnameActivator 实名激活回调接口 // 用于在手动实名后触发待实名套餐激活,避免循环依赖 type RealnameActivator interface { // ActivateByRealname 根据载体触发待实名套餐激活 ActivateByRealname(ctx context.Context, carrierType string, carrierID uint) error } type Service struct { db *gorm.DB iotCardStore *postgres.IotCardStore shopStore *postgres.ShopStore assetAllocationRecordStore *postgres.AssetAllocationRecordStore shopPackageAllocationStore *postgres.ShopPackageAllocationStore shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore packageSeriesStore *postgres.PackageSeriesStore gatewayClient *gateway.Client logger *zap.Logger pollingCallback PollingCallback dataDeductor DataDeductor realnameActivator RealnameActivator stopResumeService StopResumeServiceInterface deviceSimBindingStore *postgres.DeviceSimBindingStore redis *redis.Client assetIdentifierStore *postgres.AssetIdentifierStore assetAuditService AssetAuditService enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore enterpriseStore *postgres.EnterpriseStore } func New( db *gorm.DB, iotCardStore *postgres.IotCardStore, shopStore *postgres.ShopStore, assetAllocationRecordStore *postgres.AssetAllocationRecordStore, shopPackageAllocationStore *postgres.ShopPackageAllocationStore, shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore, packageSeriesStore *postgres.PackageSeriesStore, gatewayClient *gateway.Client, logger *zap.Logger, assetAuditService AssetAuditService, ) *Service { return &Service{ db: db, iotCardStore: iotCardStore, shopStore: shopStore, assetAllocationRecordStore: assetAllocationRecordStore, shopPackageAllocationStore: shopPackageAllocationStore, shopSeriesAllocationStore: shopSeriesAllocationStore, packageSeriesStore: packageSeriesStore, gatewayClient: gatewayClient, logger: logger, assetAuditService: assetAuditService, } } // SetPollingCallback 设置轮询回调 func (s *Service) SetPollingCallback(callback PollingCallback) { s.pollingCallback = callback } // SetAssetIdentifierStore 设置资产标识符注册表存储 func (s *Service) SetAssetIdentifierStore(store *postgres.AssetIdentifierStore) { s.assetIdentifierStore = store } // SetEnterpriseCardAuthStore 注入企业卡授权 store(用于列表响应回填企业信息) func (s *Service) SetEnterpriseCardAuthStore(store *postgres.EnterpriseCardAuthorizationStore) { s.enterpriseCardAuthStore = store } // SetEnterpriseStore 注入企业 store(用于批量加载企业名称) func (s *Service) SetEnterpriseStore(store *postgres.EnterpriseStore) { s.enterpriseStore = store } // SetDataDeductor 设置流量扣减回调 // 在应用启动时由 bootstrap 调用,注入套餐扣减服务 func (s *Service) SetDataDeductor(deductor DataDeductor) { s.dataDeductor = deductor } // SetRealnameActivator 设置实名激活回调 // 在应用启动时由 bootstrap 注入套餐激活服务 func (s *Service) SetRealnameActivator(activator RealnameActivator) { s.realnameActivator = activator } // SetStopResumeService 设置停复机服务 // 在应用启动时由 bootstrap 注入,确保手动实名后可立即评估停复机 func (s *Service) SetStopResumeService(stopResumeService StopResumeServiceInterface) { s.stopResumeService = stopResumeService } // SetDeviceSimBindingStore 设置设备绑定存储 // 在手动实名成功后用于补触发设备维度待实名套餐激活 func (s *Service) SetDeviceSimBindingStore(deviceSimBindingStore *postgres.DeviceSimBindingStore) { s.deviceSimBindingStore = deviceSimBindingStore } // SetRedisClient 设置 Redis 客户端 // 用于清理实名逆转计数器,避免历史计数干扰手动实名后的判定 func (s *Service) SetRedisClient(redisClient *redis.Client) { s.redis = redisClient } // acquireCardTrafficSyncLock 获取卡流量同步锁,避免主动刷新与轮询重复统计同一上游读数。 func (s *Service) acquireCardTrafficSyncLock(ctx context.Context, cardID uint) (bool, error) { if s.redis == nil { return true, nil } return s.redis.SetNX(ctx, constants.RedisCardTrafficSyncLockKey(cardID), "1", cardTrafficSyncLockTTL).Result() } // releaseCardTrafficSyncLock 释放卡流量同步锁。 func (s *Service) releaseCardTrafficSyncLock(ctx context.Context, cardID uint) { if s.redis == nil { return } if err := s.redis.Del(ctx, constants.RedisCardTrafficSyncLockKey(cardID)).Err(); err != nil { s.logger.Warn("释放卡流量同步锁失败", zap.Uint("card_id", cardID), zap.Error(err)) } } func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIotCardRequest) (*dto.ListStandaloneIotCardResponse, error) { page := req.Page pageSize := req.PageSize if page == 0 { page = 1 } if pageSize == 0 { pageSize = constants.DefaultPageSize } opts := &store.QueryOptions{ Page: page, PageSize: pageSize, } filters := make(map[string]interface{}) if req.Status != nil { filters["status"] = *req.Status } if req.CarrierID != nil { filters["carrier_id"] = *req.CarrierID } shopIDs, hasShopIDs := normalizeShopIDs(req.ShopIDs) if hasShopIDs { filters["shop_ids"] = shopIDs } else if req.ShopID != nil { if *req.ShopID == 0 { filters["shop_ids"] = []uint{} } else { filters["shop_id"] = *req.ShopID } } if req.ICCID != "" { filters["iccid"] = req.ICCID } if req.VirtualNo != "" { filters["virtual_no"] = req.VirtualNo } if req.MSISDN != "" { filters["msisdn"] = req.MSISDN } if req.IsStandalone != nil { filters["is_standalone"] = req.IsStandalone } if req.BatchNo != "" { filters["batch_no"] = req.BatchNo } if req.PackageID != nil { filters["package_id"] = *req.PackageID } if req.IsDistributed != nil { filters["is_distributed"] = *req.IsDistributed } if req.ICCIDStart != "" { filters["iccid_start"] = req.ICCIDStart } if req.ICCIDEnd != "" { filters["iccid_end"] = req.ICCIDEnd } if req.IsReplaced != nil { filters["is_replaced"] = *req.IsReplaced } if req.SeriesID != nil { filters["series_id"] = *req.SeriesID } if req.CarrierName != "" { filters["carrier_name"] = req.CarrierName } if req.HasActivePackage != nil { filters["has_active_package"] = *req.HasActivePackage } if req.Keyword != "" { filters["keyword"] = req.Keyword } if req.NetworkStatus != nil { filters["network_status"] = *req.NetworkStatus } if req.AuthorizedEnterpriseID != nil { filters["authorized_enterprise_id"] = *req.AuthorizedEnterpriseID } if req.IsAuthorizedToEnterprise != nil { filters["is_authorized_to_enterprise"] = *req.IsAuthorizedToEnterprise } // 代理用户注入 subordinate_shop_ids,让 Store 层走并行查询路径 // 避免 PG 对 shop_id IN (...) + ORDER BY 选择全表扫描 userType := middleware.GetUserTypeFromContext(ctx) if userType == constants.UserTypeAgent { shopID := middleware.GetShopIDFromContext(ctx) if shopID > 0 { subordinateIDs, err := s.shopStore.GetSubordinateShopIDs(ctx, shopID) if err == nil { if hasShopIDs { scopedShopIDs := intersectShopIDs(shopIDs, subordinateIDs) filters["shop_ids"] = scopedShopIDs if len(scopedShopIDs) > 1 { filters["subordinate_shop_ids"] = scopedShopIDs } } else if len(subordinateIDs) > 1 { filters["subordinate_shop_ids"] = subordinateIDs } } } } cards, total, err := s.iotCardStore.ListStandalone(ctx, opts, filters) if err != nil { return nil, err } shopMap := s.loadShopNames(ctx, cards) //TODO 这里不对,现在已经快照了,这里如果还这样处理明显是浪费的 seriesMap := s.loadSeriesNames(ctx, cards) // 批量加载企业授权信息 cardAuthMap := make(map[uint]uint) enterpriseNameMap := make(map[uint]string) if s.enterpriseCardAuthStore != nil && len(cards) > 0 { cardIDs := make([]uint, 0, len(cards)) for _, card := range cards { cardIDs = append(cardIDs, card.ID) } authMap, err := s.enterpriseCardAuthStore.GetActiveAuthEnterpriseByCardIDs(ctx, cardIDs) if err != nil { s.logger.Error("批量加载卡企业授权失败", zap.Error(err)) } else { cardAuthMap = authMap } if s.enterpriseStore != nil && len(cardAuthMap) > 0 { eIDs := make([]uint, 0, len(cardAuthMap)) seen := make(map[uint]struct{}) for _, eid := range cardAuthMap { if _, ok := seen[eid]; !ok { seen[eid] = struct{}{} eIDs = append(eIDs, eid) } } nameMap, err := s.enterpriseStore.GetNameMapByIDs(ctx, eIDs) if err != nil { s.logger.Error("批量加载企业名称失败", zap.Error(err)) } else { enterpriseNameMap = nameMap } } } list := make([]*dto.StandaloneIotCardResponse, 0, len(cards)) for _, card := range cards { item := s.toStandaloneResponse(card, shopMap, seriesMap) if eid, ok := cardAuthMap[card.ID]; ok { item.AuthorizedEnterpriseID = &eid item.AuthorizedEnterpriseName = enterpriseNameMap[eid] } list = append(list, item) } return &dto.ListStandaloneIotCardResponse{ List: list, Total: total, Page: page, PageSize: pageSize, }, nil } func normalizeShopIDs(ids []uint) ([]uint, bool) { if len(ids) == 0 { return nil, false } seen := make(map[uint]struct{}, len(ids)) result := make([]uint, 0, len(ids)) for _, id := range ids { if id == 0 { continue } if _, exists := seen[id]; exists { continue } seen[id] = struct{}{} result = append(result, id) } return result, true } func intersectShopIDs(selectedIDs, allowedIDs []uint) []uint { if len(selectedIDs) == 0 || len(allowedIDs) == 0 { return []uint{} } allowed := make(map[uint]struct{}, len(allowedIDs)) for _, id := range allowedIDs { allowed[id] = struct{}{} } result := make([]uint, 0, len(selectedIDs)) for _, id := range selectedIDs { if _, ok := allowed[id]; ok { result = append(result, id) } } return result } // GetByICCID 通过 ICCID 获取单卡详情 func (s *Service) GetByICCID(ctx context.Context, iccid string) (*dto.IotCardDetailResponse, error) { card, err := s.iotCardStore.GetByICCID(ctx, iccid) if err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeNotFound, "IoT卡不存在") } return nil, err } shopMap := s.loadShopNames(ctx, []*model.IotCard{card}) seriesMap := s.loadSeriesNames(ctx, []*model.IotCard{card}) standaloneResp := s.toStandaloneResponse(card, shopMap, seriesMap) return &dto.IotCardDetailResponse{ StandaloneIotCardResponse: *standaloneResp, }, nil } func (s *Service) loadShopNames(ctx context.Context, cards []*model.IotCard) map[uint]string { shopIDs := make([]uint, 0) shopIDSet := make(map[uint]bool) for _, card := range cards { if card.ShopID != nil && *card.ShopID > 0 && !shopIDSet[*card.ShopID] { shopIDs = append(shopIDs, *card.ShopID) shopIDSet[*card.ShopID] = true } } shopMap := make(map[uint]string) if len(shopIDs) > 0 { var shops []model.Shop // 使用 Unscoped() 包含已删除的店铺,确保能显示店铺名称 s.db.WithContext(ctx).Unscoped().Where("id IN ?", shopIDs).Find(&shops) for _, shop := range shops { shopMap[shop.ID] = shop.ShopName } } return shopMap } func (s *Service) loadSeriesNames(ctx context.Context, cards []*model.IotCard) map[uint]string { seriesIDs := make([]uint, 0) seriesIDSet := make(map[uint]bool) for _, card := range cards { if card.SeriesID != nil && *card.SeriesID > 0 && !seriesIDSet[*card.SeriesID] { seriesIDs = append(seriesIDs, *card.SeriesID) seriesIDSet[*card.SeriesID] = true } } seriesMap := make(map[uint]string) if len(seriesIDs) > 0 { var seriesList []model.PackageSeries s.db.WithContext(ctx).Where("id IN ?", seriesIDs).Find(&seriesList) for _, series := range seriesList { seriesMap[series.ID] = series.SeriesName } } return seriesMap } func (s *Service) toStandaloneResponse(card *model.IotCard, shopMap map[uint]string, seriesMap map[uint]string) *dto.StandaloneIotCardResponse { resp := &dto.StandaloneIotCardResponse{ ID: card.ID, ICCID: card.ICCID, VirtualNo: card.VirtualNo, CardCategory: card.CardCategory, CarrierID: card.CarrierID, CarrierType: card.CarrierType, CarrierName: card.CarrierName, IMSI: card.IMSI, MSISDN: card.MSISDN, BatchNo: card.BatchNo, Supplier: card.Supplier, Status: card.Status, StatusName: constants.GetIotCardStatusName(card.Status), ShopID: card.ShopID, ActivatedAt: card.ActivatedAt, ActivationStatus: card.ActivationStatus, ActivationStatusName: constants.GetActivationStatusName(card.ActivationStatus), RealNameStatus: card.RealNameStatus, RealNameStatusName: constants.GetRealNameStatusName(card.RealNameStatus), NetworkStatus: card.NetworkStatus, NetworkStatusName: constants.GetNetworkStatusName(card.NetworkStatus), GatewayExtend: card.GatewayExtend, GatewayCardIMEI: card.GatewayCardIMEI, DataUsageMB: card.DataUsageMB, CurrentMonthUsageMB: card.CurrentMonthUsageMB, LastGatewayReadingMB: card.LastGatewayReadingMB, CurrentMonthStartDate: card.CurrentMonthStartDate, LastMonthTotalMB: card.LastMonthTotalMB, LastDataCheckAt: card.LastDataCheckAt, LastRealNameCheckAt: card.LastRealNameCheckAt, EnablePolling: card.EnablePolling, SeriesID: card.SeriesID, DeviceVirtualNo: card.DeviceVirtualNo, AssetStatus: card.AssetStatus, AssetStatusName: constants.GetAssetStatusName(card.AssetStatus), Generation: card.Generation, CreatedAt: card.CreatedAt, UpdatedAt: card.UpdatedAt, } if card.ShopID != nil && *card.ShopID > 0 { resp.ShopName = shopMap[*card.ShopID] } if card.SeriesID != nil && *card.SeriesID > 0 { resp.SeriesName = seriesMap[*card.SeriesID] } return resp } func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandaloneCardsRequest, operatorID uint, operatorShopID *uint) (*dto.AllocateStandaloneCardsResponse, error) { if err := s.validateDirectSubordinate(ctx, operatorShopID, req.ToShopID); err != nil { errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardAllocate, OperationDesc: "单卡分配被拒绝", ResultStatus: constants.AssetAuditResultDenied, ErrorCode: errorCode, ErrorMsg: errorMsg, AfterData: map[string]any{ "to_shop_id": req.ToShopID, "selection_type": req.SelectionType, "requested_count": len(req.ICCIDs), }, }) return nil, err } cards, err := s.getCardsForAllocation(ctx, req, operatorShopID) if err != nil { errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardAllocate, OperationDesc: "单卡分配执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AfterData: map[string]any{ "to_shop_id": req.ToShopID, "selection_type": req.SelectionType, "requested_count": len(req.ICCIDs), }, }) return nil, err } if len(cards) == 0 { return &dto.AllocateStandaloneCardsResponse{ TotalCount: 0, SuccessCount: 0, FailCount: 0, FailedItems: []dto.AllocationFailedItem{}, }, nil } var cardIDs []uint var failedItems []dto.AllocationFailedItem boundCardIDs, err := s.iotCardStore.GetBoundCardIDs(ctx, s.extractCardIDs(cards)) if err != nil { return nil, err } boundCardIDSet := make(map[uint]bool) for _, id := range boundCardIDs { boundCardIDSet[id] = true } isPlatform := operatorShopID == nil for _, card := range cards { if boundCardIDSet[card.ID] { failedItems = append(failedItems, dto.AllocationFailedItem{ ICCID: card.ICCID, Reason: "已绑定设备的卡不能单独分配", }) continue } if isPlatform && card.Status != constants.IotCardStatusInStock { failedItems = append(failedItems, dto.AllocationFailedItem{ ICCID: card.ICCID, Reason: "平台只能分配在库状态的卡", }) continue } if !isPlatform && card.Status != constants.IotCardStatusDistributed { failedItems = append(failedItems, dto.AllocationFailedItem{ ICCID: card.ICCID, Reason: "代理只能分配已分销状态的卡", }) continue } cardIDs = append(cardIDs, card.ID) } if len(cardIDs) == 0 { s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardAllocate, OperationDesc: "单卡分配被拒绝", ResultStatus: constants.AssetAuditResultDenied, ErrorMsg: "无可分配卡", BatchTotal: len(cards), FailCount: len(failedItems), AfterData: map[string]any{ "to_shop_id": req.ToShopID, "failed_items": failedItems, "selection_type": req.SelectionType, }, }) return &dto.AllocateStandaloneCardsResponse{ TotalCount: len(cards), SuccessCount: 0, FailCount: len(failedItems), FailedItems: failedItems, }, nil } newStatus := constants.IotCardStatusDistributed toShopID := req.ToShopID err = s.db.Transaction(func(tx *gorm.DB) error { txIotCardStore := postgres.NewIotCardStore(tx, nil) txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil) if err := txIotCardStore.BatchUpdateShopIDAndStatus(ctx, cardIDs, &toShopID, newStatus); err != nil { return err } allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate) records := s.buildAllocationRecords(cards, cardIDs, operatorShopID, toShopID, operatorID, allocationNo, req.Remark) return txRecordStore.BatchCreate(ctx, records) }) if err != nil { errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardAllocate, OperationDesc: "单卡分配执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, BatchTotal: len(cards), SuccessCount: len(cardIDs), FailCount: len(failedItems), AfterData: map[string]any{ "to_shop_id": req.ToShopID, "failed_items": failedItems, }, }) return nil, err } s.iotCardStore.InvalidateListCountCache(ctx) // 通知轮询调度器状态变化(卡被分配后可能需要重新匹配配置) if s.pollingCallback != nil && len(cardIDs) > 0 { for _, cardID := range cardIDs { s.pollingCallback.OnCardStatusChanged(ctx, cardID) } } shopMap := s.loadShopNames(ctx, cards) targetShopName := s.getAuditShopName(ctx, &req.ToShopID) beforeData := make([]map[string]any, 0, len(cards)) for _, card := range cards { beforeData = append(beforeData, map[string]any{ "id": card.ID, "iccid": card.ICCID, "shop_id": card.ShopID, "shop_name": shopMapValue(shopMap, card.ShopID), "status": card.Status, }) } s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardAllocate, OperationDesc: "单卡分配", ResultStatus: constants.AssetAuditResultSuccess, BatchTotal: len(cards), SuccessCount: len(cardIDs), FailCount: len(failedItems), BeforeData: map[string]any{ "cards": beforeData, }, AfterData: map[string]any{ "to_shop_id": req.ToShopID, "to_shop_name": targetShopName, "new_status": newStatus, "failed_items": failedItems, }, }) return &dto.AllocateStandaloneCardsResponse{ TotalCount: len(cards), SuccessCount: len(cardIDs), FailCount: len(failedItems), AllocationNo: s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate), FailedItems: failedItems, }, nil } func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCardsRequest, operatorID uint, operatorShopID *uint) (*dto.RecallStandaloneCardsResponse, error) { // 1. 查询卡列表 cards, err := s.getCardsForRecall(ctx, req) if err != nil { errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardRecall, OperationDesc: "单卡回收执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AfterData: map[string]any{ "selection_type": req.SelectionType, "requested_count": len(req.ICCIDs), }, }) return nil, err } if len(cards) == 0 { return &dto.RecallStandaloneCardsResponse{ TotalCount: 0, SuccessCount: 0, FailCount: 0, FailedItems: []dto.AllocationFailedItem{}, }, nil } // 2. 收集所有卡的店铺 ID,批量查询店铺信息以验证直属下级关系 shopIDSet := make(map[uint]bool) for _, card := range cards { if card.ShopID != nil { shopIDSet[*card.ShopID] = true } } shopIDs := make([]uint, 0, len(shopIDSet)) for shopID := range shopIDSet { shopIDs = append(shopIDs, shopID) } // 3. 批量查询店铺,验证哪些是直属下级 directSubordinateSet := make(map[uint]bool) if len(shopIDs) > 0 { shops, err := s.shopStore.GetByIDs(ctx, shopIDs) if err != nil { return nil, err } for _, shop := range shops { if s.isDirectSubordinate(operatorShopID, shop) { directSubordinateSet[shop.ID] = true } } } // 4. 检查绑定设备的卡 var cardIDs []uint var successCards []*model.IotCard var failedItems []dto.AllocationFailedItem boundCardIDs, err := s.iotCardStore.GetBoundCardIDs(ctx, s.extractCardIDs(cards)) if err != nil { return nil, err } boundCardIDSet := make(map[uint]bool) for _, id := range boundCardIDs { boundCardIDSet[id] = true } // 5. 逐卡验证:绑定设备、所属店铺是否是直属下级 for _, card := range cards { if boundCardIDSet[card.ID] { failedItems = append(failedItems, dto.AllocationFailedItem{ ICCID: card.ICCID, Reason: "已绑定设备的卡不能单独回收", }) continue } if card.ShopID == nil { failedItems = append(failedItems, dto.AllocationFailedItem{ ICCID: card.ICCID, Reason: "卡未分配给任何店铺", }) continue } userType := middleware.GetUserTypeFromContext(ctx) if userType == constants.UserTypeAgent { if !directSubordinateSet[*card.ShopID] { failedItems = append(failedItems, dto.AllocationFailedItem{ ICCID: card.ICCID, Reason: "卡所属店铺不是您的直属下级", }) continue } } cardIDs = append(cardIDs, card.ID) successCards = append(successCards, card) } if len(cardIDs) == 0 { s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardRecall, OperationDesc: "单卡回收被拒绝", ResultStatus: constants.AssetAuditResultDenied, ErrorMsg: "无可回收卡", BatchTotal: len(cards), FailCount: len(failedItems), AfterData: map[string]any{ "failed_items": failedItems, "selection_type": req.SelectionType, }, }) return &dto.RecallStandaloneCardsResponse{ TotalCount: len(cards), SuccessCount: 0, FailCount: len(failedItems), FailedItems: failedItems, }, nil } // 6. 执行回收 isPlatform := operatorShopID == nil var newShopID *uint var newStatus int if isPlatform { newShopID = nil newStatus = constants.IotCardStatusInStock } else { newShopID = operatorShopID newStatus = constants.IotCardStatusDistributed } allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall) err = s.db.Transaction(func(tx *gorm.DB) error { txIotCardStore := postgres.NewIotCardStore(tx, nil) txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil) if err := txIotCardStore.BatchUpdateShopIDAndStatus(ctx, cardIDs, newShopID, newStatus); err != nil { return err } records := s.buildRecallRecords(successCards, operatorShopID, operatorID, allocationNo, req.Remark) return txRecordStore.BatchCreate(ctx, records) }) if err != nil { errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardRecall, OperationDesc: "单卡回收执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, BatchTotal: len(cards), SuccessCount: len(cardIDs), FailCount: len(failedItems), AfterData: map[string]any{ "failed_items": failedItems, }, }) return nil, err } s.iotCardStore.InvalidateListCountCache(ctx) // 通知轮询调度器状态变化(卡被回收后可能需要重新匹配配置) if s.pollingCallback != nil && len(cardIDs) > 0 { for _, cardID := range cardIDs { s.pollingCallback.OnCardStatusChanged(ctx, cardID) } } shopMap := s.loadShopNames(ctx, successCards) targetShopName := s.getAuditRecallTargetShopName(ctx, newShopID) beforeData := make([]map[string]any, 0, len(successCards)) for _, card := range successCards { beforeData = append(beforeData, map[string]any{ "id": card.ID, "iccid": card.ICCID, "shop_id": card.ShopID, "shop_name": shopMapValue(shopMap, card.ShopID), "status": card.Status, }) } s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardRecall, OperationDesc: "单卡回收", ResultStatus: constants.AssetAuditResultSuccess, BatchTotal: len(cards), SuccessCount: len(cardIDs), FailCount: len(failedItems), BeforeData: map[string]any{ "cards": beforeData, }, AfterData: map[string]any{ "to_shop_id": newShopID, "target_shop_name": targetShopName, "new_status": newStatus, "failed_items": failedItems, }, }) return &dto.RecallStandaloneCardsResponse{ TotalCount: len(cards), SuccessCount: len(cardIDs), FailCount: len(failedItems), AllocationNo: allocationNo, FailedItems: failedItems, }, nil } // isDirectSubordinate 检查店铺是否是操作者的可回收范围 // 平台用户可以回收所有店铺的卡,代理用户只能回收直属下级店铺的卡 func (s *Service) isDirectSubordinate(operatorShopID *uint, shop *model.Shop) bool { if operatorShopID == nil { // 平台用户:可以回收所有店铺的卡 return true } // 代理用户:直属下级是 parent_id 等于自己的店铺 return shop.ParentID != nil && *shop.ParentID == *operatorShopID } func (s *Service) validateDirectSubordinate(ctx context.Context, operatorShopID *uint, targetShopID uint) error { if operatorShopID != nil && *operatorShopID == targetShopID { return errors.ErrCannotAllocateToSelf } targetShop, err := s.shopStore.GetByID(ctx, targetShopID) if err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeShopNotFound) } return err } // 平台/超级管理员可跨级分配到任意店铺(前置已校验目标店铺存在) if operatorShopID == nil { return nil } // 代理仅允许分配给直属下级店铺 if targetShop.ParentID == nil || *targetShop.ParentID != *operatorShopID { return errors.ErrNotDirectSubordinate } return nil } func (s *Service) getAuditShopName(ctx context.Context, shopID *uint) string { if shopID == nil || *shopID == 0 { return "" } var shop model.Shop if err := s.db.WithContext(ctx).Unscoped().First(&shop, *shopID).Error; err != nil { return "" } return shop.ShopName } func (s *Service) getAuditRecallTargetShopName(ctx context.Context, shopID *uint) string { if shopID == nil { return "平台库存" } return s.getAuditShopName(ctx, shopID) } func shopMapValue(shopMap map[uint]string, shopID *uint) string { if shopID == nil { return "" } return shopMap[*shopID] } func (s *Service) getCardsForAllocation(ctx context.Context, req *dto.AllocateStandaloneCardsRequest, operatorShopID *uint) ([]*model.IotCard, error) { switch req.SelectionType { case dto.SelectionTypeList: return s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs) case dto.SelectionTypeRange: return s.iotCardStore.GetStandaloneByICCIDRange(ctx, req.ICCIDStart, req.ICCIDEnd, operatorShopID) case dto.SelectionTypeFilter: filters := make(map[string]any) if req.CarrierID != nil { filters["carrier_id"] = *req.CarrierID } if req.BatchNo != "" { filters["batch_no"] = req.BatchNo } if req.Status != nil { filters["status"] = *req.Status } return s.iotCardStore.GetStandaloneByFilters(ctx, filters, operatorShopID) default: return nil, errors.New(errors.CodeInvalidParam, "无效的选卡方式") } } func (s *Service) getCardsForRecall(ctx context.Context, req *dto.RecallStandaloneCardsRequest) ([]*model.IotCard, error) { switch req.SelectionType { case dto.SelectionTypeList: return s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs) case dto.SelectionTypeRange: // 查询已分配给店铺的单卡(回收场景) return s.iotCardStore.GetDistributedStandaloneByICCIDRange(ctx, req.ICCIDStart, req.ICCIDEnd) case dto.SelectionTypeFilter: filters := make(map[string]any) if req.CarrierID != nil { filters["carrier_id"] = *req.CarrierID } if req.BatchNo != "" { filters["batch_no"] = req.BatchNo } // 查询已分配给店铺的单卡(回收场景) return s.iotCardStore.GetDistributedStandaloneByFilters(ctx, filters) default: return nil, errors.New(errors.CodeInvalidParam, "无效的选卡方式") } } func (s *Service) extractCardIDs(cards []*model.IotCard) []uint { ids := make([]uint, len(cards)) for i, card := range cards { ids[i] = card.ID } return ids } func (s *Service) buildAllocationRecords(cards []*model.IotCard, successCardIDs []uint, fromShopID *uint, toShopID uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord { successIDSet := make(map[uint]bool) for _, id := range successCardIDs { successIDSet[id] = true } var records []*model.AssetAllocationRecord for _, card := range cards { if !successIDSet[card.ID] { continue } record := &model.AssetAllocationRecord{ AllocationNo: allocationNo, AllocationType: constants.AssetAllocationTypeAllocate, AssetType: constants.AssetTypeIotCard, AssetID: card.ID, AssetIdentifier: card.ICCID, ToOwnerType: constants.OwnerTypeShop, ToOwnerID: toShopID, OperatorID: operatorID, Remark: remark, } if fromShopID == nil { record.FromOwnerType = constants.OwnerTypePlatform record.FromOwnerID = nil } else { record.FromOwnerType = constants.OwnerTypeShop record.FromOwnerID = fromShopID } records = append(records, record) } return records } func (s *Service) buildRecallRecords(successCards []*model.IotCard, toShopID *uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord { var records []*model.AssetAllocationRecord for _, card := range successCards { record := &model.AssetAllocationRecord{ AllocationNo: allocationNo, AllocationType: constants.AssetAllocationTypeRecall, AssetType: constants.AssetTypeIotCard, AssetID: card.ID, AssetIdentifier: card.ICCID, FromOwnerType: constants.OwnerTypeShop, FromOwnerID: card.ShopID, // 从卡的当前所属店铺获取 OperatorID: operatorID, Remark: remark, } if toShopID == nil { record.ToOwnerType = constants.OwnerTypePlatform record.ToOwnerID = 0 } else { record.ToOwnerType = constants.OwnerTypeShop record.ToOwnerID = *toShopID } records = append(records, record) } return records } // BatchSetSeriesBinding 批量设置卡的套餐系列绑定 func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCardSeriesBindngRequest, operatorShopID *uint) (*dto.BatchSetCardSeriesBindngResponse, error) { selectionType, err := normalizeCardSeriesBindingSelection(req) if err != nil { return nil, err } cards, err := s.getCardsForSeriesBinding(ctx, req, selectionType) batchTotal := cardSeriesBindingBatchTotal(req, selectionType, cards) auditData := cardSeriesBindingAuditData(req, selectionType) if err != nil { errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardSeriesBinding, OperationDesc: "卡系列绑定执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, BatchTotal: batchTotal, AfterData: auditData, }) return nil, err } if len(cards) == 0 { failedItems := []dto.CardSeriesBindngFailedItem{} if selectionType == dto.SelectionTypeList { failedItems = s.buildCardNotFoundFailedItems(req.ICCIDs) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardSeriesBinding, OperationDesc: "卡系列绑定被拒绝", ResultStatus: constants.AssetAuditResultDenied, ErrorMsg: "卡不存在", BatchTotal: batchTotal, FailCount: len(failedItems), AfterData: auditData, }) } return &dto.BatchSetCardSeriesBindngResponse{ SuccessCount: 0, FailCount: len(failedItems), FailedItems: failedItems, }, nil } cardMap := indexCardsByICCID(cards) // 验证系列存在(仅当 SeriesID > 0 时) if req.SeriesID > 0 { packageSeries, err := s.packageSeriesStore.GetByID(ctx, req.SeriesID) if err != nil { if err == gorm.ErrRecordNotFound { denyErr := errors.New(errors.CodeNotFound, "套餐系列不存在或已禁用") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardSeriesBinding, OperationDesc: "卡系列绑定被拒绝", ResultStatus: constants.AssetAuditResultDenied, ErrorCode: errorCode, ErrorMsg: errorMsg, BatchTotal: batchTotal, AfterData: auditData, }) return nil, denyErr } errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardSeriesBinding, OperationDesc: "卡系列绑定执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, BatchTotal: batchTotal, AfterData: auditData, }) return nil, err } if packageSeries.Status != 1 { denyErr := errors.New(errors.CodeInvalidParam, "套餐系列不存在或已禁用") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardSeriesBinding, OperationDesc: "卡系列绑定被拒绝", ResultStatus: constants.AssetAuditResultDenied, ErrorCode: errorCode, ErrorMsg: errorMsg, BatchTotal: batchTotal, AfterData: auditData, }) return nil, denyErr } } var successCardIDs []uint var failedItems []dto.CardSeriesBindngFailedItem successCardIDSet := make(map[uint]struct{}) hasSeriesAllocation := true if operatorShopID != nil && req.SeriesID > 0 { hasSeriesAllocation, err = s.hasAvailableSeriesAllocation(ctx, *operatorShopID, req.SeriesID) if err != nil { return nil, err } } addCard := func(card *model.IotCard, requestedICCID string) { if card == nil { failedItems = append(failedItems, dto.CardSeriesBindngFailedItem{ ICCID: requestedICCID, Reason: "卡不存在", }) return } if !hasSeriesAllocation { failedItems = append(failedItems, dto.CardSeriesBindngFailedItem{ ICCID: card.ICCID, Reason: "您没有权限分配该套餐系列", }) return } // 代理只能操作自己店铺名下的卡,保持旧接口权限语义不变。 if operatorShopID != nil && (card.ShopID == nil || *card.ShopID != *operatorShopID) { failedItems = append(failedItems, dto.CardSeriesBindngFailedItem{ ICCID: card.ICCID, Reason: "无权操作此卡", }) return } if _, exists := successCardIDSet[card.ID]; exists { return } successCardIDSet[card.ID] = struct{}{} successCardIDs = append(successCardIDs, card.ID) } if selectionType == dto.SelectionTypeList { for _, iccid := range req.ICCIDs { addCard(cardMap[iccid], iccid) } } else { for _, card := range cards { addCard(card, card.ICCID) } } if len(successCardIDs) > 0 { var seriesIDPtr *uint if req.SeriesID > 0 { seriesIDPtr = &req.SeriesID } if err := s.iotCardStore.BatchUpdateSeriesID(ctx, successCardIDs, seriesIDPtr); err != nil { errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardSeriesBinding, OperationDesc: "卡系列绑定执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, BatchTotal: batchTotal, SuccessCount: len(successCardIDs), FailCount: len(failedItems), AfterData: map[string]any{ "selection_type": selectionType, "series_id": req.SeriesID, "success_ids": successCardIDs, "failed_items": failedItems, }, }) return nil, err } } resultStatus := constants.AssetAuditResultSuccess operationDesc := "批量设置卡系列绑定" errorMsg := "" if len(successCardIDs) == 0 && len(failedItems) > 0 { resultStatus = constants.AssetAuditResultDenied operationDesc = "卡系列绑定被拒绝" errorMsg = "无可操作卡" } beforeCards := make([]map[string]any, 0, len(successCardIDs)) successSet := make(map[uint]struct{}, len(successCardIDs)) for _, id := range successCardIDs { successSet[id] = struct{}{} } for _, card := range cards { if _, ok := successSet[card.ID]; !ok { continue } beforeCards = append(beforeCards, map[string]any{ "id": card.ID, "iccid": card.ICCID, "series_id": card.SeriesID, }) } s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardSeriesBinding, OperationDesc: operationDesc, ResultStatus: resultStatus, ErrorMsg: errorMsg, BatchTotal: batchTotal, SuccessCount: len(successCardIDs), FailCount: len(failedItems), BeforeData: map[string]any{ "cards": beforeCards, }, AfterData: map[string]any{ "selection_type": selectionType, "series_id": req.SeriesID, "success_ids": successCardIDs, "failed_items": failedItems, }, }) return &dto.BatchSetCardSeriesBindngResponse{ SuccessCount: len(successCardIDs), FailCount: len(failedItems), FailedItems: failedItems, }, nil } func normalizeCardSeriesBindingSelection(req *dto.BatchSetCardSeriesBindngRequest) (string, error) { switch req.SelectionType { case dto.SelectionTypeList: if len(req.ICCIDs) == 0 { return "", errors.New(errors.CodeInvalidParam, "selection_type=list时iccids不能为空") } return dto.SelectionTypeList, nil case dto.SelectionTypeRange: if req.ICCIDStart == "" || req.ICCIDEnd == "" { return "", errors.New(errors.CodeInvalidParam, "selection_type=range时iccid_start和iccid_end不能为空") } if req.ICCIDStart > req.ICCIDEnd { return "", errors.New(errors.CodeInvalidParam, "ICCID起始号不能大于结束号") } return dto.SelectionTypeRange, nil case dto.SelectionTypeFilter: return dto.SelectionTypeFilter, nil case "": if len(req.ICCIDs) > 0 { return dto.SelectionTypeList, nil } if req.ICCIDStart != "" || req.ICCIDEnd != "" { if req.ICCIDStart == "" || req.ICCIDEnd == "" { return "", errors.New(errors.CodeInvalidParam, "iccid_start和iccid_end必须同时传入") } if req.ICCIDStart > req.ICCIDEnd { return "", errors.New(errors.CodeInvalidParam, "ICCID起始号不能大于结束号") } return dto.SelectionTypeRange, nil } if hasCardSeriesBindingFilters(req) { return dto.SelectionTypeFilter, nil } return "", errors.New(errors.CodeInvalidParam, "请选择要设置套餐系列的卡") default: return "", errors.New(errors.CodeInvalidParam, "无效的选卡方式") } } func hasCardSeriesBindingFilters(req *dto.BatchSetCardSeriesBindngRequest) bool { return req.Status != nil || req.CarrierID != nil || req.ShopID != nil || len(req.ShopIDs) > 0 || req.FilterSeriesID != nil || req.ICCID != "" || req.MSISDN != "" || req.IsStandalone != nil || req.BatchNo != "" || req.PackageID != nil || req.IsDistributed != nil || req.IsReplaced != nil || req.CarrierName != "" } func (s *Service) getCardsForSeriesBinding(ctx context.Context, req *dto.BatchSetCardSeriesBindngRequest, selectionType string) ([]*model.IotCard, error) { switch selectionType { case dto.SelectionTypeList: return s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs) case dto.SelectionTypeRange: filters := map[string]any{ "iccid_start": req.ICCIDStart, "iccid_end": req.ICCIDEnd, } return s.iotCardStore.GetBySeriesBindingFilters(ctx, filters) case dto.SelectionTypeFilter: return s.iotCardStore.GetBySeriesBindingFilters(ctx, buildCardSeriesBindingFilters(req)) default: return nil, errors.New(errors.CodeInvalidParam, "无效的选卡方式") } } func buildCardSeriesBindingFilters(req *dto.BatchSetCardSeriesBindngRequest) map[string]any { filters := make(map[string]any) if req.Status != nil { filters["status"] = *req.Status } if req.CarrierID != nil { filters["carrier_id"] = *req.CarrierID } shopIDs, hasShopIDs := normalizeShopIDs(req.ShopIDs) if hasShopIDs { filters["shop_ids"] = shopIDs } else if req.ShopID != nil { if *req.ShopID == 0 { filters["shop_ids"] = []uint{} } else { filters["shop_id"] = *req.ShopID } } if req.FilterSeriesID != nil { filters["series_id"] = *req.FilterSeriesID } if req.ICCID != "" { filters["iccid"] = req.ICCID } if req.MSISDN != "" { filters["msisdn"] = req.MSISDN } if req.IsStandalone != nil { filters["is_standalone"] = req.IsStandalone } if req.BatchNo != "" { filters["batch_no"] = req.BatchNo } if req.PackageID != nil { filters["package_id"] = *req.PackageID } if req.IsDistributed != nil { filters["is_distributed"] = *req.IsDistributed } if req.ICCIDStart != "" { filters["iccid_start"] = req.ICCIDStart } if req.ICCIDEnd != "" { filters["iccid_end"] = req.ICCIDEnd } if req.IsReplaced != nil { filters["is_replaced"] = *req.IsReplaced } if req.CarrierName != "" { filters["carrier_name"] = req.CarrierName } return filters } func (s *Service) hasAvailableSeriesAllocation(ctx context.Context, shopID uint, seriesID uint) (bool, error) { seriesAllocations, err := s.shopSeriesAllocationStore.GetByShopID(ctx, shopID) if err != nil { return false, err } for _, alloc := range seriesAllocations { if alloc.SeriesID == seriesID && alloc.Status == 1 { return true, nil } } return false, nil } func indexCardsByICCID(cards []*model.IotCard) map[string]*model.IotCard { cardMap := make(map[string]*model.IotCard, len(cards)) for _, card := range cards { if card.ICCID != "" { cardMap[card.ICCID] = card } if card.ICCID19 != "" { cardMap[card.ICCID19] = card } if card.ICCID20 != nil && *card.ICCID20 != "" { cardMap[*card.ICCID20] = card } } return cardMap } func cardSeriesBindingBatchTotal(req *dto.BatchSetCardSeriesBindngRequest, selectionType string, cards []*model.IotCard) int { if selectionType == dto.SelectionTypeList { return len(req.ICCIDs) } return len(cards) } func cardSeriesBindingAuditData(req *dto.BatchSetCardSeriesBindngRequest, selectionType string) map[string]any { data := map[string]any{ "selection_type": selectionType, "series_id": req.SeriesID, } switch selectionType { case dto.SelectionTypeList: data["iccids"] = req.ICCIDs case dto.SelectionTypeRange: data["iccid_start"] = req.ICCIDStart data["iccid_end"] = req.ICCIDEnd case dto.SelectionTypeFilter: data["filters"] = buildCardSeriesBindingFilters(req) } return data } func (s *Service) buildCardNotFoundFailedItems(iccids []string) []dto.CardSeriesBindngFailedItem { items := make([]dto.CardSeriesBindngFailedItem, len(iccids)) for i, iccid := range iccids { items[i] = dto.CardSeriesBindngFailedItem{ ICCID: iccid, Reason: "卡不存在", } } return items } // RefreshCardDataFromGateway 从 Gateway 完整同步卡数据 // 调用网关查询网络状态、实名状态、本月流量,并写回数据库 // 流量采用增量计算(与轮询逻辑一致):increment = 当前网关读数 - 上次网关读数 func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string) error { card, err := s.iotCardStore.GetByICCID(ctx, iccid) if err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeNotFound, "IoT卡不存在") } return err } locked, lockErr := s.acquireCardTrafficSyncLock(ctx, card.ID) if lockErr != nil { return errors.Wrap(errors.CodeInternalError, lockErr, "获取卡流量同步锁失败") } else if !locked { return errors.New(errors.CodeTooManyRequests, "卡流量正在同步,请稍后重试") } else { defer s.releaseCardTrafficSyncLock(ctx, card.ID) latestCard, loadErr := s.iotCardStore.GetByID(ctx, card.ID) if loadErr != nil { return errors.Wrap(errors.CodeInternalError, loadErr, "刷新卡数据失败") } card = latestCard } syncTime := time.Now() updates := map[string]any{ "last_sync_time": syncTime, } var flowIncrementMB float64 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 { gatewayExtend := strings.TrimSpace(statusResp.Extend) updates["gateway_extend"] = gatewayExtend networkStatus, ok := gateway.ParseCardNetworkStatus(statusResp.CardStatus, gatewayExtend) if !ok { s.logger.Warn("刷新卡数据:未知 Gateway 卡状态", zap.String("iccid", iccid), zap.String("card_status", statusResp.CardStatus), zap.String("extend", gatewayExtend)) } else { updates["network_status"] = networkStatus } } // 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 // 检测 0→1 变化:旧状态非已实名且新状态为已实名,补写 first_realname_at if card.RealNameStatus != constants.RealNameStatusVerified && realNameStatus == constants.RealNameStatusVerified { updates["first_realname_at"] = syncTime } } // 3. 查询本月流量用量 — 使用增量计算(与轮询 calculateFlowUpdates 逻辑一致) flowResp, err := s.gatewayClient.QueryFlow(ctx, &gateway.FlowQueryReq{ CardNo: iccid, }) if err != nil { s.logger.Warn("刷新卡数据:查询流量失败", zap.String("iccid", iccid), zap.Error(err)) } else { flowIncrementMB = s.calculateRefreshFlowUpdates(card, float64(flowResp.Used), syncTime, updates) } } if err := s.iotCardStore.UpdateFields(ctx, card.ID, updates); err != nil { return errors.Wrap(errors.CodeInternalError, err, "更新卡数据失败") } // 有增量时触发套餐流量扣减 if flowIncrementMB > 0 && s.dataDeductor != nil { if err := s.dataDeductor.DeductDataUsage(ctx, constants.AssetTypeIotCard, card.ID, flowIncrementMB); err != nil { s.logger.Warn("手动刷新:套餐流量扣减失败", zap.Uint("card_id", card.ID), zap.Float64("increment_mb", flowIncrementMB), zap.Error(err)) } } // 失效轮询缓存,确保轮询系统读到最新的 network_status/real_name_status/流量 if s.pollingCallback != nil { s.pollingCallback.OnCardStatusChanged(ctx, card.ID) } s.logger.Info("刷新卡数据成功", zap.String("iccid", iccid), zap.Uint("card_id", card.ID), zap.Float64("flow_increment_mb", flowIncrementMB)) return nil } // calculateRefreshFlowUpdates 计算手动刷新时的流量增量并填充 updates // 算法与轮询 calculateFlowUpdates 一致:增量 = 当前网关读数 - 上次网关读数 // 返回本次增量值(MB),用于触发套餐扣减 func (s *Service) calculateRefreshFlowUpdates(card *model.IotCard, gatewayFlowMB float64, now time.Time, updates map[string]any) float64 { increment := gatewayFlowMB - card.LastGatewayReadingMB shouldUpdateGatewayReading := true if increment < 0 { // 当前值比上次小,检查是否为上游运营商正常重置 resetDay := s.getCarrierResetDay(card.CarrierID) if isRefreshResetWindow(now, resetDay) { // 在重置日窗口内,本次原始值即为增量 increment = gatewayFlowMB s.logger.Info("手动刷新:检测到上游运营商重置", zap.Uint("card_id", card.ID), zap.Float64("gateway_flow", gatewayFlowMB), zap.Float64("last_reading", card.LastGatewayReadingMB), zap.Int("reset_day", resetDay)) } else { // 非重置日出现值下降,异常,不计入 s.logger.Warn("手动刷新:流量异常,非重置日出现值下降", zap.Uint("card_id", card.ID), zap.Float64("gateway_flow", gatewayFlowMB), zap.Float64("last_reading", card.LastGatewayReadingMB)) increment = 0 shouldUpdateGatewayReading = false } } if shouldUpdateGatewayReading { updates["last_gateway_reading_mb"] = gatewayFlowMB } // 检测跨自然月 currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) isCrossMonth := card.CurrentMonthStartDate == nil || card.CurrentMonthStartDate.Before(currentMonthStart) if isCrossMonth { s.logger.Info("手动刷新:检测到跨月,重置流量计数", zap.Uint("card_id", card.ID), zap.Float64("last_month_total", card.CurrentMonthUsageMB)) updates["last_month_total_mb"] = card.CurrentMonthUsageMB updates["current_month_start_date"] = currentMonthStart if increment > 0 { updates["current_month_usage_mb"] = increment } else { updates["current_month_usage_mb"] = float64(0) } } else if increment > 0 { // 同月内,增量累加(使用 gorm.Expr 保证原子性) updates["current_month_usage_mb"] = gorm.Expr("current_month_usage_mb + ?", increment) } // 更新卡生命周期总用量 if increment > 0 { updates["data_usage_mb"] = gorm.Expr("data_usage_mb + ?", int64(increment)) } // 首次流量查询初始化 if card.CurrentMonthStartDate == nil { updates["current_month_start_date"] = currentMonthStart } return increment } // getCarrierResetDay 读取运营商的上游流量重置日 func (s *Service) getCarrierResetDay(carrierID uint) int { var carrier model.Carrier if err := s.db.Select("data_reset_day").First(&carrier, carrierID).Error; err != nil { return 1 } if carrier.DataResetDay == 0 { return 1 } return carrier.DataResetDay } // isRefreshResetWindow 判断今天是否在运营商重置日窗口内 // 窗口 = 重置日当天 + 前一天(容错网关数据延迟) func isRefreshResetWindow(now time.Time, resetDay int) bool { today := now.Day() if today == resetDay { return true } resetDate := time.Date(now.Year(), now.Month(), resetDay, 0, 0, 0, 0, now.Location()) prevDay := resetDate.AddDate(0, 0, -1).Day() return today == prevDay } // parseGatewayRealnameStatus 将网关返回的实名状态布尔值转换为 real_name_status 数值 // true=已实名(1),false=未实名(0) func parseGatewayRealnameStatus(realStatus bool) int { if realStatus { return constants.RealNameStatusVerified } return constants.RealNameStatusNotVerified } // UpdatePollingStatus 更新卡的轮询状态 // 启用或禁用卡的轮询功能 func (s *Service) UpdatePollingStatus(ctx context.Context, cardID uint, enablePolling bool) error { card, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { if err == gorm.ErrRecordNotFound { denyErr := errors.New(errors.CodeNotFound, "IoT卡不存在") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardPollingStatus, OperationDesc: "更新卡轮询状态被拒绝", ResultStatus: constants.AssetAuditResultDenied, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: cardID, AssetIdentifier: "", AfterData: map[string]any{ "enable_polling": enablePolling, }, }) return denyErr } errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardPollingStatus, OperationDesc: "更新卡轮询状态执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: cardID, AfterData: map[string]any{ "enable_polling": enablePolling, }, }) return err } // 检查是否需要更新 if card.EnablePolling == enablePolling { s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardPollingStatus, OperationDesc: "更新卡轮询状态", ResultStatus: constants.AssetAuditResultSuccess, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: map[string]any{ "enable_polling": card.EnablePolling, }, AfterData: map[string]any{ "enable_polling": enablePolling, }, }) return nil // 状态未变化 } // 更新数据库 card.EnablePolling = enablePolling if err := s.iotCardStore.Update(ctx, card); err != nil { errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardPollingStatus, OperationDesc: "更新卡轮询状态执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: map[string]any{ "enable_polling": !enablePolling, }, AfterData: map[string]any{ "enable_polling": enablePolling, }, }) return err } s.logger.Info("更新卡轮询状态", zap.Uint("card_id", cardID), zap.Bool("enable_polling", enablePolling), ) // 通知轮询调度器 if s.pollingCallback != nil { if enablePolling { s.pollingCallback.OnCardEnabled(ctx, cardID) } else { s.pollingCallback.OnCardDisabled(ctx, cardID) } } s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardPollingStatus, OperationDesc: "更新卡轮询状态", ResultStatus: constants.AssetAuditResultSuccess, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: map[string]any{ "enable_polling": !enablePolling, }, AfterData: map[string]any{ "enable_polling": enablePolling, }, }) return nil } // BatchUpdatePollingStatus 批量更新卡的轮询状态 func (s *Service) BatchUpdatePollingStatus(ctx context.Context, cardIDs []uint, enablePolling bool) error { if len(cardIDs) == 0 { return nil } // 批量更新数据库 if err := s.iotCardStore.BatchUpdatePollingStatus(ctx, cardIDs, enablePolling); err != nil { errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardPollingStatus, OperationDesc: "批量更新卡轮询状态执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, BatchTotal: len(cardIDs), FailCount: len(cardIDs), AfterData: map[string]any{ "card_ids": cardIDs, "enable_polling": enablePolling, "trigger_source": "batch", }, }) return err } s.logger.Info("批量更新卡轮询状态", zap.Int("count", len(cardIDs)), zap.Bool("enable_polling", enablePolling), ) // 通知轮询调度器 if s.pollingCallback != nil { for _, cardID := range cardIDs { if enablePolling { s.pollingCallback.OnCardEnabled(ctx, cardID) } else { s.pollingCallback.OnCardDisabled(ctx, cardID) } } } s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardPollingStatus, OperationDesc: "批量更新卡轮询状态", ResultStatus: constants.AssetAuditResultSuccess, BatchTotal: len(cardIDs), SuccessCount: len(cardIDs), AfterData: map[string]any{ "card_ids": cardIDs, "enable_polling": enablePolling, "trigger_source": "batch", }, }) return nil } // DeleteCard 删除卡(软删除) func (s *Service) DeleteCard(ctx context.Context, cardID uint) error { card, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { if err == gorm.ErrRecordNotFound { denyErr := errors.New(errors.CodeNotFound, "IoT卡不存在") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardDelete, OperationDesc: "删除卡被拒绝", ResultStatus: constants.AssetAuditResultDenied, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: cardID, }) return denyErr } errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardDelete, OperationDesc: "删除卡执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: cardID, }) return err } if err := s.iotCardStore.Delete(ctx, cardID); err != nil { errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardDelete, OperationDesc: "删除卡执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: cardSnapshot(card), }) return err } if s.assetIdentifierStore != nil { _ = s.assetIdentifierStore.DeleteByAsset(ctx, model.AssetTypeIotCard, cardID) } s.logger.Info("删除卡", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID)) if s.pollingCallback != nil { s.pollingCallback.OnCardDeleted(ctx, cardID) } s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardDelete, OperationDesc: "删除卡", ResultStatus: constants.AssetAuditResultSuccess, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: cardSnapshot(card), AfterData: map[string]any{ "deleted": true, }, }) return nil } // BatchDeleteCards 批量删除卡(软删除) func (s *Service) BatchDeleteCards(ctx context.Context, cardIDs []uint) error { if len(cardIDs) == 0 { return nil } cards, queryErr := s.iotCardStore.GetByIDs(ctx, cardIDs) if queryErr != nil { errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(queryErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardBatchDelete, OperationDesc: "批量删除卡执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, BatchTotal: len(cardIDs), FailCount: len(cardIDs), AfterData: map[string]any{ "card_ids": cardIDs, }, }) return queryErr } // 批量软删除 if err := s.iotCardStore.BatchDelete(ctx, cardIDs); err != nil { errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardBatchDelete, OperationDesc: "批量删除卡执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, BatchTotal: len(cardIDs), FailCount: len(cardIDs), AfterData: map[string]any{ "card_ids": cardIDs, }, }) return err } s.logger.Info("批量删除卡", zap.Int("count", len(cardIDs))) // 通知轮询调度器 if s.pollingCallback != nil { for _, cardID := range cardIDs { s.pollingCallback.OnCardDeleted(ctx, cardID) } } beforeCards := make([]map[string]any, 0, len(cards)) for _, card := range cards { beforeCards = append(beforeCards, cardSnapshot(card)) } s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardBatchDelete, OperationDesc: "批量删除卡", ResultStatus: constants.AssetAuditResultSuccess, BatchTotal: len(cardIDs), SuccessCount: len(cardIDs), BeforeData: map[string]any{ "cards": beforeCards, }, AfterData: map[string]any{ "card_ids": cardIDs, "deleted": true, }, }) return nil } // UpdateRealnamePolicy 更新卡的实名认证策略 func (s *Service) UpdateRealnamePolicy(ctx context.Context, cardID uint, realnamePolicy string) error { // 检查卡是否存在 card, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { if err == gorm.ErrRecordNotFound { denyErr := errors.New(errors.CodeNotFound, "IoT卡不存在") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardRealnamePolicy, OperationDesc: "更新卡实名认证策略被拒绝", ResultStatus: constants.AssetAuditResultDenied, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: cardID, AfterData: map[string]any{ "realname_policy": realnamePolicy, }, }) return denyErr } wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardRealnamePolicy, OperationDesc: "更新卡实名认证策略执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: cardID, AfterData: map[string]any{ "realname_policy": realnamePolicy, }, }) return wrapErr } // 幂等检查 if card.RealnamePolicy == realnamePolicy { s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardRealnamePolicy, OperationDesc: "更新卡实名认证策略", ResultStatus: constants.AssetAuditResultSuccess, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: map[string]any{ "realname_policy": card.RealnamePolicy, }, AfterData: map[string]any{ "realname_policy": realnamePolicy, }, }) return nil } // 更新数据库 if err := s.iotCardStore.UpdateRealnamePolicy(ctx, cardID, realnamePolicy); err != nil { wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新实名认证策略失败") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardRealnamePolicy, OperationDesc: "更新卡实名认证策略执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: map[string]any{ "realname_policy": card.RealnamePolicy, }, AfterData: map[string]any{ "realname_policy": realnamePolicy, }, }) return wrapErr } s.logger.Info("更新卡实名认证策略", zap.Uint("card_id", cardID), zap.String("realname_policy", realnamePolicy), ) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardRealnamePolicy, OperationDesc: "更新卡实名认证策略", ResultStatus: constants.AssetAuditResultSuccess, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: map[string]any{ "realname_policy": card.RealnamePolicy, }, AfterData: map[string]any{ "realname_policy": realnamePolicy, }, }) return nil } // ManualUpdateRealnameStatus 手动更新卡实名状态 // 用于人工纠偏实名状态,并同步触发实名相关业务(套餐激活、停复机评估、轮询缓存失效) func (s *Service) ManualUpdateRealnameStatus(ctx context.Context, cardID uint, realNameStatus int) (*model.IotCard, error) { if realNameStatus != constants.RealNameStatusNotVerified && realNameStatus != constants.RealNameStatusVerified { denyErr := errors.New(errors.CodeInvalidParam, "无效的实名状态") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardRealnameStatus, OperationDesc: "手动更新卡实名状态被拒绝", ResultStatus: constants.AssetAuditResultDenied, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: cardID, AfterData: map[string]any{ "real_name_status": realNameStatus, }, }) return nil, denyErr } card, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { if err == gorm.ErrRecordNotFound { denyErr := errors.New(errors.CodeNotFound, "IoT卡不存在") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardRealnameStatus, OperationDesc: "手动更新卡实名状态被拒绝", ResultStatus: constants.AssetAuditResultDenied, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: cardID, AfterData: map[string]any{ "real_name_status": realNameStatus, }, }) return nil, denyErr } wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardRealnameStatus, OperationDesc: "手动更新卡实名状态执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: cardID, AfterData: map[string]any{ "real_name_status": realNameStatus, }, }) return nil, wrapErr } oldStatus := card.RealNameStatus statusChanged := oldStatus != realNameStatus now := time.Now() fields := map[string]any{ "last_real_name_check_at": now, } if statusChanged { fields["real_name_status"] = realNameStatus } if statusChanged && oldStatus != constants.RealNameStatusVerified && realNameStatus == constants.RealNameStatusVerified { fields["first_realname_at"] = now } if err := s.iotCardStore.UpdateFields(ctx, cardID, fields); err != nil { wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡实名状态失败") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardRealnameStatus, OperationDesc: "手动更新卡实名状态执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: map[string]any{ "real_name_status": oldStatus, }, AfterData: map[string]any{ "real_name_status": realNameStatus, }, }) return nil, wrapErr } freshCard, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "查询更新后的IoT卡失败") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardRealnameStatus, OperationDesc: "手动更新卡实名状态执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: map[string]any{ "real_name_status": oldStatus, }, AfterData: map[string]any{ "real_name_status": realNameStatus, }, }) return nil, wrapErr } if statusChanged { s.handleManualRealnameStatusChanged(ctx, oldStatus, freshCard) } if s.pollingCallback != nil { s.pollingCallback.OnCardStatusChanged(ctx, cardID) } s.logger.Info("手动更新卡实名状态", zap.Uint("card_id", cardID), zap.Int("old_status", oldStatus), zap.Int("new_status", realNameStatus), zap.Uint("operator_id", middleware.GetUserIDFromContext(ctx))) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardRealnameStatus, OperationDesc: "手动更新卡实名状态", ResultStatus: constants.AssetAuditResultSuccess, AssetID: freshCard.ID, AssetIdentifier: freshCard.ICCID, BeforeData: map[string]any{ "real_name_status": oldStatus, "first_realname_at": card.FirstRealnameAt, }, AfterData: map[string]any{ "real_name_status": freshCard.RealNameStatus, "first_realname_at": freshCard.FirstRealnameAt, }, }) return freshCard, nil } // handleManualRealnameStatusChanged 处理手动实名状态变更后的联动逻辑 func (s *Service) handleManualRealnameStatusChanged(ctx context.Context, oldStatus int, card *model.IotCard) { if card == nil { return } s.clearRealnameReversalCounter(ctx, card.ID) if oldStatus != constants.RealNameStatusVerified && card.RealNameStatus == constants.RealNameStatusVerified { s.triggerRealnameActivation(ctx, constants.AssetTypeIotCard, card.ID) if s.deviceSimBindingStore != nil { binding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID) if err == nil && binding != nil { s.triggerRealnameActivation(ctx, constants.AssetTypeDevice, binding.DeviceID) } else if err != nil && !stderrors.Is(err, gorm.ErrRecordNotFound) { s.logger.Warn("手动实名后查询绑定设备失败", zap.Uint("card_id", card.ID), zap.Error(err)) } } } if s.stopResumeService != nil { if err := s.stopResumeService.EvaluateAndAct(ctx, card); err != nil { s.logger.Warn("手动实名后触发停复机评估失败", zap.Uint("card_id", card.ID), zap.Error(err)) } } } // triggerRealnameActivation 触发待实名套餐激活 func (s *Service) triggerRealnameActivation(ctx context.Context, carrierType string, carrierID uint) { if s.realnameActivator == nil { return } if err := s.realnameActivator.ActivateByRealname(ctx, carrierType, carrierID); err != nil { s.logger.Warn("手动实名后触发待实名套餐激活失败", zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID), zap.Error(err)) } } // clearRealnameReversalCounter 清理实名逆转连续计数器 func (s *Service) clearRealnameReversalCounter(ctx context.Context, cardID uint) { if s.redis == nil { return } if err := s.redis.Del(ctx, constants.RedisPollingRealnameReversalCountKey(cardID)).Err(); err != nil { s.logger.Warn("清理实名逆转计数器失败", zap.Uint("card_id", cardID), zap.Error(err)) } }