Files
junhong_cmp_fiber/internal/service/iot_card/service.go
break c64f3d8b80
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m31s
全局审计完成
2026-08-07 11:02:52 +08:00

2026 lines
72 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package iot_card
import (
"context"
stderrors "errors"
"strings"
"time"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/cardtrafficlock"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"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/google/uuid"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// PollingCallback 轮询回调接口
// 用于在卡生命周期事件发生时通知轮询调度器
type PollingCallback interface {
// OnCardCreated 单卡创建时的回调
OnCardCreated(ctx context.Context, card *model.IotCard)
// OnCardStatusChanged 卡状态变化时的回调
OnCardStatusChanged(ctx context.Context, cardID uint)
// OnBatchCardsStatusChanged 批量卡状态变化时的回调(批量分配/回收场景)
OnBatchCardsStatusChanged(ctx context.Context, cardIDs []uint)
// OnCardDeleted 卡删除时的回调
OnCardDeleted(ctx context.Context, cardID uint)
// OnCardEnabled 卡启用轮询时的回调
OnCardEnabled(ctx context.Context, cardID uint)
// OnCardDisabled 卡禁用轮询时的回调
OnCardDisabled(ctx context.Context, cardID uint)
}
// 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
realnameActivator RealnameActivator
stopResumeService StopResumeServiceInterface
deviceSimBindingStore *postgres.DeviceSimBindingStore
redis *redis.Client
assetIdentifierStore *postgres.AssetIdentifierStore
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore
enterpriseStore *postgres.EnterpriseStore
packageExpiryQuery *packageexpiry.Query
cardObservation *cardapp.Service
observationSeries cardapp.BestEffortSeriesDispatcher
trafficLock *cardtrafficlock.Lock
speedTierIntegration speedTierIntegrationLog
auditWriter *audit.Writer
}
// SetObservationSeriesDispatcher 注入获取实名链接后的后台观测端口。
func (s *Service) SetObservationSeriesDispatcher(dispatcher cardapp.BestEffortSeriesDispatcher) {
s.observationSeries = dispatcher
}
// SetCardObservationService 注入统一卡实名观测写入用例。
func (s *Service) SetCardObservationService(service *cardapp.Service) {
s.cardObservation = service
}
// SetSpeedTierIntegrationLog 注入卡固定限速的 Integration Log 接缝。
func (s *Service) SetSpeedTierIntegrationLog(integration speedTierIntegrationLog) {
s.speedTierIntegration = integration
}
// SetPackageExpiryQuery 注入套餐最终到期查询,供列表使用批量投影。
func (s *Service) SetPackageExpiryQuery(query *packageexpiry.Query) {
s.packageExpiryQuery = query
}
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,
) *Service {
return &Service{
db: db,
iotCardStore: iotCardStore,
shopStore: shopStore,
assetAllocationRecordStore: assetAllocationRecordStore,
shopPackageAllocationStore: shopPackageAllocationStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
packageSeriesStore: packageSeriesStore,
gatewayClient: gatewayClient,
logger: logger,
packageExpiryQuery: packageexpiry.NewQuery(db),
}
}
// 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
}
// 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
s.trafficLock = cardtrafficlock.New(redisClient)
}
// acquireCardTrafficSyncLock 获取卡流量同步锁,避免主动刷新与轮询重复统计同一上游读数。
func (s *Service) acquireCardTrafficSyncLock(ctx context.Context, cardID uint) (string, bool, error) {
return s.trafficLock.Acquire(ctx, cardID)
}
// releaseCardTrafficSyncLock 释放卡流量同步锁。
func (s *Service) releaseCardTrafficSyncLock(ctx context.Context, cardID uint, token string) {
if err := s.trafficLock.Release(ctx, cardID, token); 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.RealNameStatus != nil {
filters["real_name_status"] = *req.RealNameStatus
}
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
}
cardIDs := make([]uint, 0, len(cards))
for _, card := range cards {
cardIDs = append(cardIDs, card.ID)
}
expiryEstimates, err := s.packageExpiryQuery.ResolveBatch(ctx, constants.AssetTypeIotCard, cardIDs)
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)
item.PackageExpiryEstimate = expiryEstimates[card.ID]
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,
RealnamePolicy: card.RealnamePolicy,
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 {
return nil, err
}
cards, err := s.getCardsForAllocation(ctx, req, operatorShopID)
if err != nil {
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 {
outcomes := cardAuditOutcomes(cards, constants.AuditResultFailed, "IoT 卡分配失败")
s.recordCardTransferAuditFailure(ctx,
constants.AuditActionIotCardAllocationBatch, constants.AuditActionIotCardAllocated,
"allocate", "批量分配 IoT 卡失败", constants.AuditResultFailed,
cards, outcomes, &req.ToShopID, constants.IotCardStatusDistributed,
len(cards), 0, len(cards), err)
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 {
denyErr := errors.New(errors.CodeInvalidStatus, "无可分配卡")
outcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "IoT 卡不可分配")
for _, item := range failedItems {
setCardAuditOutcomeByICCID(outcomes, cards, item.ICCID, constants.AuditResultDenied, item.Reason)
}
s.recordCardTransferAuditFailure(ctx,
constants.AuditActionIotCardAllocationBatch, constants.AuditActionIotCardAllocated,
"allocate", "批量分配 IoT 卡被拒绝", constants.AuditResultDenied,
cards, outcomes, &req.ToShopID, constants.IotCardStatusDistributed,
len(cards), 0, len(failedItems), denyErr)
return &dto.AllocateStandaloneCardsResponse{
TotalCount: len(cards),
SuccessCount: 0,
FailCount: len(failedItems),
FailedItems: failedItems,
}, nil
}
newStatus := constants.IotCardStatusDistributed
toShopID := req.ToShopID
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate)
records := s.buildAllocationRecords(cards, cardIDs, operatorShopID, toShopID, operatorID, allocationNo, req.Remark)
outcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "IoT 卡不可分配")
for _, item := range failedItems {
setCardAuditOutcomeByICCID(outcomes, cards, item.ICCID, constants.AuditResultDenied, item.Reason)
}
setCardAuditOutcomes(outcomes, cardIDs, constants.AuditResultSuccess, "IoT 卡已分配")
auditResult := constants.AuditResultSuccess
if len(failedItems) > 0 {
auditResult = constants.AuditResultPartial
}
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
}
if err := txRecordStore.BatchCreate(ctx, records); err != nil {
return err
}
return s.appendCardTransferAudit(ctx, tx,
constants.AuditActionIotCardAllocationBatch, constants.AuditActionIotCardAllocated,
"allocate", "批量分配 IoT 卡", auditResult,
cards, outcomes, records, &toShopID, newStatus,
len(cards), len(cardIDs), len(failedItems), nil)
})
if err != nil {
failedOutcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "IoT 卡不可分配")
for _, item := range failedItems {
setCardAuditOutcomeByICCID(failedOutcomes, cards, item.ICCID, constants.AuditResultDenied, item.Reason)
}
setCardAuditOutcomes(failedOutcomes, cardIDs, constants.AuditResultFailed, "IoT 卡分配失败")
s.recordCardTransferAuditFailure(ctx,
constants.AuditActionIotCardAllocationBatch, constants.AuditActionIotCardAllocated,
"allocate", "批量分配 IoT 卡失败", constants.AuditResultFailed,
cards, failedOutcomes, &toShopID, newStatus,
len(cards), 0, len(cards), err)
return nil, err
}
s.iotCardStore.InvalidateListCountCache(ctx)
// 通知轮询调度器状态变化(异步执行 + 批量操作,避免 N 次单卡 DB 查询打满连接池)
if s.pollingCallback != nil && len(cardIDs) > 0 {
cardIDsCopy := make([]uint, len(cardIDs))
copy(cardIDsCopy, cardIDs)
cb := s.pollingCallback
go func() {
cb.OnBatchCardsStatusChanged(context.Background(), cardIDsCopy)
}()
}
return &dto.AllocateStandaloneCardsResponse{
TotalCount: len(cards),
SuccessCount: len(cardIDs),
FailCount: len(failedItems),
AllocationNo: allocationNo,
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 {
return nil, err
}
if len(cards) == 0 {
return &dto.RecallStandaloneCardsResponse{
TotalCount: 0,
SuccessCount: 0,
FailCount: 0,
FailedItems: []dto.AllocationFailedItem{},
}, nil
}
newShopID := operatorShopID
newStatus := constants.IotCardStatusDistributed
if operatorShopID == nil {
newStatus = constants.IotCardStatusInStock
}
// 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 {
outcomes := cardAuditOutcomes(cards, constants.AuditResultFailed, "IoT 卡回收失败")
s.recordCardTransferAuditFailure(ctx,
constants.AuditActionIotCardRecallBatch, constants.AuditActionIotCardRecalled,
"recall", "批量回收 IoT 卡失败", constants.AuditResultFailed,
cards, outcomes, newShopID, newStatus, len(cards), 0, len(cards), err)
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 {
outcomes := cardAuditOutcomes(cards, constants.AuditResultFailed, "IoT 卡回收失败")
s.recordCardTransferAuditFailure(ctx,
constants.AuditActionIotCardRecallBatch, constants.AuditActionIotCardRecalled,
"recall", "批量回收 IoT 卡失败", constants.AuditResultFailed,
cards, outcomes, newShopID, newStatus, len(cards), 0, len(cards), err)
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 {
denyErr := errors.New(errors.CodeInvalidStatus, "无可回收卡")
outcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "IoT 卡不可回收")
for _, item := range failedItems {
setCardAuditOutcomeByICCID(outcomes, cards, item.ICCID, constants.AuditResultDenied, item.Reason)
}
s.recordCardTransferAuditFailure(ctx,
constants.AuditActionIotCardRecallBatch, constants.AuditActionIotCardRecalled,
"recall", "批量回收 IoT 卡被拒绝", constants.AuditResultDenied,
cards, outcomes, newShopID, newStatus,
len(cards), 0, len(failedItems), denyErr)
return &dto.RecallStandaloneCardsResponse{
TotalCount: len(cards),
SuccessCount: 0,
FailCount: len(failedItems),
FailedItems: failedItems,
}, nil
}
// 6. 执行回收
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall)
records := s.buildRecallRecords(successCards, operatorShopID, operatorID, allocationNo, req.Remark)
outcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "IoT 卡不可回收")
for _, item := range failedItems {
setCardAuditOutcomeByICCID(outcomes, cards, item.ICCID, constants.AuditResultDenied, item.Reason)
}
setCardAuditOutcomes(outcomes, cardIDs, constants.AuditResultSuccess, "IoT 卡已回收")
auditResult := constants.AuditResultSuccess
if len(failedItems) > 0 {
auditResult = constants.AuditResultPartial
}
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
}
if err := txRecordStore.BatchCreate(ctx, records); err != nil {
return err
}
return s.appendCardTransferAudit(ctx, tx,
constants.AuditActionIotCardRecallBatch, constants.AuditActionIotCardRecalled,
"recall", "批量回收 IoT 卡", auditResult,
cards, outcomes, records, newShopID, newStatus,
len(cards), len(cardIDs), len(failedItems), nil)
})
if err != nil {
failedOutcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "IoT 卡不可回收")
for _, item := range failedItems {
setCardAuditOutcomeByICCID(failedOutcomes, cards, item.ICCID, constants.AuditResultDenied, item.Reason)
}
setCardAuditOutcomes(failedOutcomes, cardIDs, constants.AuditResultFailed, "IoT 卡回收失败")
s.recordCardTransferAuditFailure(ctx,
constants.AuditActionIotCardRecallBatch, constants.AuditActionIotCardRecalled,
"recall", "批量回收 IoT 卡失败", constants.AuditResultFailed,
cards, failedOutcomes, newShopID, newStatus,
len(cards), 0, len(cards), err)
return nil, err
}
s.iotCardStore.InvalidateListCountCache(ctx)
// 通知轮询调度器状态变化(异步批量执行,避免回收大批卡时打满 DB 连接池)
if s.pollingCallback != nil && len(cardIDs) > 0 {
cardIDsCopy := make([]uint, len(cardIDs))
copy(cardIDsCopy, cardIDs)
cb := s.pollingCallback
go func() {
cb.OnBatchCardsStatusChanged(context.Background(), cardIDsCopy)
}()
}
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) 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 {
return nil, err
}
if len(cards) == 0 {
failedItems := []dto.CardSeriesBindngFailedItem{}
if selectionType == dto.SelectionTypeList {
failedItems = s.buildCardNotFoundFailedItems(req.ICCIDs)
}
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, "套餐系列不存在或已禁用")
outcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "套餐系列不存在或已禁用")
targetSeriesID := req.SeriesID
s.recordCardSeriesBindingAuditFailure(ctx, cards, outcomes, &targetSeriesID,
constants.AuditResultDenied, batchTotal, 0, batchTotal, auditData, denyErr)
return nil, denyErr
}
outcomes := cardAuditOutcomes(cards, constants.AuditResultFailed, "IoT 卡系列绑定失败")
targetSeriesID := req.SeriesID
s.recordCardSeriesBindingAuditFailure(ctx, cards, outcomes, &targetSeriesID,
constants.AuditResultFailed, batchTotal, 0, batchTotal, auditData, err)
return nil, err
}
if packageSeries.Status != 1 {
denyErr := errors.New(errors.CodeInvalidParam, "套餐系列不存在或已禁用")
outcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "套餐系列不存在或已禁用")
targetSeriesID := req.SeriesID
s.recordCardSeriesBindingAuditFailure(ctx, cards, outcomes, &targetSeriesID,
constants.AuditResultDenied, batchTotal, 0, batchTotal, auditData, denyErr)
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 {
outcomes := cardAuditOutcomes(cards, constants.AuditResultFailed, "IoT 卡系列绑定失败")
targetSeriesID := req.SeriesID
s.recordCardSeriesBindingAuditFailure(ctx, cards, outcomes, &targetSeriesID,
constants.AuditResultFailed, batchTotal, 0, batchTotal, auditData, err)
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 && len(failedItems) > 0 {
denyErr := errors.New(errors.CodeInvalidStatus, "无可操作卡")
outcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "无权设置 IoT 卡系列绑定")
for _, item := range failedItems {
setCardAuditOutcomeByICCID(outcomes, cards, item.ICCID, constants.AuditResultDenied, item.Reason)
}
var seriesIDPtr *uint
if req.SeriesID > 0 {
seriesIDPtr = &req.SeriesID
}
s.recordCardSeriesBindingAuditFailure(ctx, cards, outcomes, seriesIDPtr,
constants.AuditResultDenied, batchTotal, 0, len(failedItems), auditData, denyErr)
}
if len(successCardIDs) > 0 {
var seriesIDPtr *uint
if req.SeriesID > 0 {
seriesIDPtr = &req.SeriesID
}
outcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "IoT 卡系列绑定被拒绝")
for _, item := range failedItems {
setCardAuditOutcomeByICCID(outcomes, cards, item.ICCID, constants.AuditResultDenied, item.Reason)
}
setCardAuditOutcomes(outcomes, successCardIDs, constants.AuditResultSuccess, "IoT 卡系列绑定已更新")
auditResult := constants.AuditResultSuccess
if len(failedItems) > 0 {
auditResult = constants.AuditResultPartial
}
err = s.db.Transaction(func(tx *gorm.DB) error {
txIotCardStore := postgres.NewIotCardStore(tx, nil)
if err := txIotCardStore.BatchUpdateSeriesID(ctx, successCardIDs, seriesIDPtr); err != nil {
return err
}
return s.appendCardSeriesBindingAudit(ctx, tx, cards, outcomes, seriesIDPtr, auditResult,
batchTotal, len(successCardIDs), len(failedItems), auditData, nil)
})
if err != nil {
failedOutcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "IoT 卡系列绑定被拒绝")
for _, item := range failedItems {
setCardAuditOutcomeByICCID(failedOutcomes, cards, item.ICCID, constants.AuditResultDenied, item.Reason)
}
setCardAuditOutcomes(failedOutcomes, successCardIDs, constants.AuditResultFailed, "IoT 卡系列绑定失败")
s.recordCardSeriesBindingAuditFailure(ctx, cards, failedOutcomes, seriesIDPtr,
constants.AuditResultFailed, batchTotal, 0, batchTotal, auditData, err)
return nil, err
}
}
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
}
// RefreshCardDataByID 通过卡 ID 从 Gateway 同步卡数据
// 内部查询 ICCID 后委托给 RefreshCardDataFromGateway供套餐失效前流量同步使用
func (s *Service) RefreshCardDataByID(ctx context.Context, cardID uint) error {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return err
}
return s.RefreshCardDataFromGateway(ctx, card.ICCID)
}
// 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
}
lockToken, locked, lockErr := s.acquireCardTrafficSyncLock(ctx, card.ID)
if lockErr != nil {
wrapErr := errors.Wrap(errors.CodeInternalError, lockErr, "获取卡流量同步锁失败")
s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, wrapErr)
return wrapErr
} else if !locked {
denyErr := errors.New(errors.CodeTooManyRequests, "卡流量正在同步,请稍后重试")
s.recordCardRefreshFailure(ctx, card, constants.AuditResultDenied, denyErr)
return denyErr
} else {
defer s.releaseCardTrafficSyncLock(ctx, card.ID, lockToken)
latestCard, loadErr := s.iotCardStore.GetByID(ctx, card.ID)
if loadErr != nil {
wrapErr := errors.Wrap(errors.CodeInternalError, loadErr, "刷新卡数据失败")
s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, wrapErr)
return wrapErr
}
card = latestCard
}
syncTime := time.Now()
var flowIncrementMB float64
var refreshSuccess, refreshFailed, refreshUnknown int
seriesKey := requestIDFromContext(ctx)
if seriesKey == "" {
seriesKey = uuid.NewString()
}
if s.gatewayClient != nil {
// 1. 查询网络状态(卡的开/停机状态)
statusObserver := &gatewayCardAttemptObserver{service: s, card: card,
operation: constants.IntegrationOperationGatewayNetwork, scene: constants.CardObservationSceneManualRefresh, seriesKey: seriesKey}
statusResp, callErr := s.gatewayClient.QueryCardStatus(gateway.WithAttemptObserver(ctx, statusObserver), &gateway.CardStatusReq{CardNo: iccid})
statusAttempt := statusObserver.successful
if statusObserver.recordingErr != nil && statusObserver.lastCallErr == nil {
if statusObserver.unknown {
refreshUnknown++
} else {
refreshFailed++
}
s.logger.Warn("刷新卡数据:记录网络状态查询尝试失败,跳过外呼", zap.String("iccid", iccid), zap.Error(statusObserver.recordingErr))
} else if statusObserver.recordingErr != nil {
wrapErr := errors.Wrap(errors.CodeDatabaseError, statusObserver.recordingErr, "终结网络状态查询记录失败")
result := constants.AuditResultFailed
if statusObserver.unknown {
result = constants.AuditResultUnknown
}
s.recordCardRefreshFailure(ctx, card, result, wrapErr)
return wrapErr
} else {
if callErr != nil {
if statusAttempt != nil {
if logErr := s.completeCardRefreshAttempt(ctx, card, statusAttempt, callErr, false, "终结网络状态查询记录失败"); logErr != nil {
return logErr
}
}
if statusObserver.unknown {
refreshUnknown++
} else {
refreshFailed++
}
s.logger.Warn("刷新卡数据:查询网络状态失败", zap.String("iccid", iccid), zap.Error(callErr))
} else if strings.TrimSpace(statusResp.ICCID) == "" {
refreshFailed++
invalidRespErr := errors.New(errors.CodeGatewayInvalidResp, "Gateway 网络状态响应缺少 ICCID")
if logErr := s.completeCardRefreshAttempt(ctx, card, statusAttempt, invalidRespErr, false, "终结网络状态查询记录失败"); logErr != nil {
return logErr
}
s.logger.Warn("刷新卡数据:网络状态响应缺少 ICCID跳过本次网络观测", zap.Uint("card_id", card.ID))
} else if s.cardObservation == nil {
configErr := errors.New(errors.CodeInternalError, "卡网络观测能力未配置")
if logErr := s.completeCardRefreshAttempt(ctx, card, statusAttempt, nil, false, "终结网络状态查询记录失败"); logErr != nil {
return logErr
}
s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, configErr)
return configErr
} else {
requestID := requestIDFromContext(ctx)
decision, applyErr := s.cardObservation.ApplyNetworkObservation(ctx, carddomain.NetworkObservation{
CardID: card.ID, GatewayStatus: statusResp.CardStatus,
GatewayExtend: statusResp.Extend, GatewayIMEI: statusResp.IMEI,
Metadata: carddomain.ObservationMetadata{
ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync,
Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime,
RequestID: requestID, CorrelationID: requestID,
},
})
if applyErr != nil {
if logErr := s.completeCardRefreshAttempt(ctx, card, statusAttempt, nil, false, "终结网络状态查询记录失败"); logErr != nil {
return logErr
}
s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, applyErr)
return applyErr
}
stateChanged := decision.StatusChanged || decision.StopReasonChanged || decision.StopPolling ||
decision.GatewayExtend != card.GatewayExtend || decision.UpdateIMEI && decision.GatewayIMEI != card.GatewayCardIMEI
if logErr := s.completeCardRefreshAttempt(ctx, card, statusAttempt, nil, stateChanged, "终结网络状态查询记录失败"); logErr != nil {
return logErr
}
if !decision.StatusKnown {
refreshUnknown++
s.logger.Warn("刷新卡数据:未知 Gateway 卡状态",
zap.String("iccid", iccid),
zap.String("card_status", statusResp.CardStatus),
zap.String("extend", strings.TrimSpace(statusResp.Extend)))
} else {
refreshSuccess++
}
}
}
// 2. 查询实名状态
realnameObserver := &gatewayCardAttemptObserver{service: s, card: card,
operation: constants.IntegrationOperationGatewayRealname, scene: constants.CardObservationSceneManualRefresh, seriesKey: seriesKey}
realnameResp, callErr := s.gatewayClient.QueryRealnameStatus(gateway.WithAttemptObserver(ctx, realnameObserver), &gateway.CardStatusReq{CardNo: iccid})
realnameAttempt := realnameObserver.successful
if realnameObserver.recordingErr != nil && realnameObserver.lastCallErr == nil {
if realnameObserver.unknown {
refreshUnknown++
} else {
refreshFailed++
}
s.logger.Warn("刷新卡数据:记录实名状态查询尝试失败,跳过外呼", zap.String("iccid", iccid), zap.Error(realnameObserver.recordingErr))
} else if realnameObserver.recordingErr != nil {
wrapErr := errors.Wrap(errors.CodeDatabaseError, realnameObserver.recordingErr, "终结实名状态查询记录失败")
result := constants.AuditResultFailed
if realnameObserver.unknown {
result = constants.AuditResultUnknown
}
s.recordCardRefreshFailure(ctx, card, result, wrapErr)
return wrapErr
} else {
if callErr != nil {
if realnameAttempt != nil {
if logErr := s.completeCardRefreshAttempt(ctx, card, realnameAttempt, callErr, false, "终结实名状态查询记录失败"); logErr != nil {
return logErr
}
}
if realnameObserver.unknown {
refreshUnknown++
} else {
refreshFailed++
}
s.logger.Warn("刷新卡数据:查询实名状态失败", zap.String("iccid", iccid), zap.Error(callErr))
} else if strings.TrimSpace(realnameResp.ICCID) == "" {
refreshFailed++
invalidRespErr := errors.New(errors.CodeGatewayInvalidResp, "Gateway 实名状态响应缺少 ICCID")
if logErr := s.completeCardRefreshAttempt(ctx, card, realnameAttempt, invalidRespErr, false, "终结实名状态查询记录失败"); logErr != nil {
return logErr
}
s.logger.Warn("刷新卡数据:实名响应缺少 ICCID跳过本次实名观测", zap.Uint("card_id", card.ID))
} else if s.cardObservation == nil {
configErr := errors.New(errors.CodeInternalError, "卡实名观测能力未配置")
if logErr := s.completeCardRefreshAttempt(ctx, card, realnameAttempt, nil, false, "终结实名状态查询记录失败"); logErr != nil {
return logErr
}
s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, configErr)
return configErr
} else {
requestID := requestIDFromContext(ctx)
decision, applyErr := s.cardObservation.ApplyCardObservation(ctx, carddomain.RealnameObservation{
CardID: card.ID,
Verified: realnameResp.RealStatus,
Metadata: carddomain.ObservationMetadata{
ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync,
Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime,
RequestID: requestID, CorrelationID: requestID,
},
})
if applyErr != nil {
if logErr := s.completeCardRefreshAttempt(ctx, card, realnameAttempt, nil, false, "终结实名状态查询记录失败"); logErr != nil {
return logErr
}
s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, applyErr)
return applyErr
}
if logErr := s.completeCardRefreshAttempt(ctx, card, realnameAttempt, nil, decision.StatusChanged, "终结实名状态查询记录失败"); logErr != nil {
return logErr
}
refreshSuccess++
}
}
// 3. 查询本月流量用量 — 使用增量计算(与轮询 calculateFlowUpdates 逻辑一致)
flowObserver := &gatewayCardAttemptObserver{service: s, card: card,
operation: constants.IntegrationOperationGatewayTraffic, scene: constants.CardObservationSceneManualRefresh, seriesKey: seriesKey}
flowResp, callErr := s.gatewayClient.QueryFlow(gateway.WithAttemptObserver(ctx, flowObserver), &gateway.FlowQueryReq{CardNo: iccid})
flowAttempt := flowObserver.successful
if flowObserver.recordingErr != nil && flowObserver.lastCallErr == nil {
if flowObserver.unknown {
refreshUnknown++
} else {
refreshFailed++
}
s.logger.Warn("刷新卡数据:记录流量查询尝试失败,跳过外呼", zap.String("iccid", iccid), zap.Error(flowObserver.recordingErr))
} else if flowObserver.recordingErr != nil {
wrapErr := errors.Wrap(errors.CodeDatabaseError, flowObserver.recordingErr, "终结流量查询记录失败")
result := constants.AuditResultFailed
if flowObserver.unknown {
result = constants.AuditResultUnknown
}
s.recordCardRefreshFailure(ctx, card, result, wrapErr)
return wrapErr
} else {
if callErr != nil {
if flowAttempt != nil {
if logErr := s.completeCardRefreshAttempt(ctx, card, flowAttempt, callErr, false, "终结流量查询记录失败"); logErr != nil {
return logErr
}
}
if flowObserver.unknown {
refreshUnknown++
} else {
refreshFailed++
}
s.logger.Warn("刷新卡数据:查询流量失败", zap.String("iccid", iccid), zap.Error(callErr))
} else if s.cardObservation == nil {
configErr := errors.New(errors.CodeInternalError, "卡流量观测能力未配置")
if logErr := s.completeCardRefreshAttempt(ctx, card, flowAttempt, nil, false, "终结流量查询记录失败"); logErr != nil {
return logErr
}
s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, configErr)
return configErr
} else {
requestID := requestIDFromContext(ctx)
resetDay := postgres.NewCarrierStore(s.db).GetDataResetDay(ctx, card.CarrierID)
decision, applyErr := s.cardObservation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{
CardID: card.ID, GatewayReadingMB: float64(flowResp.Used), ResetDay: resetDay,
Metadata: carddomain.ObservationMetadata{
ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync,
Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime,
RequestID: requestID, CorrelationID: requestID,
},
})
if applyErr != nil {
if logErr := s.completeCardRefreshAttempt(ctx, card, flowAttempt, nil, false, "终结流量查询记录失败"); logErr != nil {
return logErr
}
s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, applyErr)
return applyErr
}
stateChanged := decision.IncrementMB != 0 || decision.CrossMonth || decision.LastGatewayReadingMB != card.LastGatewayReadingMB
if logErr := s.completeCardRefreshAttempt(ctx, card, flowAttempt, nil, stateChanged, "终结流量查询记录失败"); logErr != nil {
return logErr
}
refreshSuccess++
flowIncrementMB = decision.IncrementMB
}
}
}
refreshResult, refreshSummary := constants.AuditResultSuccess, "人工刷新 IoT 卡"
switch {
case s.gatewayClient == nil || refreshSuccess == 0 && refreshUnknown == 0:
refreshResult, refreshSummary = constants.AuditResultFailed, "人工刷新 IoT 卡未完成"
case refreshUnknown > 0:
refreshResult, refreshSummary = constants.AuditResultUnknown, "人工刷新 IoT 卡结果待核对"
case refreshFailed > 0:
refreshResult, refreshSummary = constants.AuditResultPartial, "人工刷新 IoT 卡部分完成"
}
if err := s.updateCardRefreshCompletion(ctx, card, syncTime, refreshResult, refreshSummary); err != nil {
wrapErr := errors.Wrap(errors.CodeInternalError, err, "更新卡数据失败")
s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, wrapErr)
return wrapErr
}
// 失效轮询缓存,确保轮询系统读到最新的 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.String("result", refreshResult),
zap.Float64("flow_increment_mb", flowIncrementMB))
if refreshResult == constants.AuditResultFailed {
return errors.New(errors.CodeGatewayError, "刷新卡数据未完成")
}
if refreshResult == constants.AuditResultUnknown {
return errors.New(errors.CodeGatewayTimeout, "刷新卡数据结果待核对")
}
return nil
}
// 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 {
appErr := errors.Wrap(errors.CodeDatabaseError, err, "查询 IoT 卡失败")
result := constants.AuditResultFailed
if err == gorm.ErrRecordNotFound {
appErr = errors.New(errors.CodeNotFound, "IoT卡不存在")
result = constants.AuditResultDenied
}
s.recordPollingStatusFailure(ctx, constants.AuditActionIotCardPollingStatusUpdated, result, &model.IotCard{Model: gorm.Model{ID: cardID}}, enablePolling, appErr)
return appErr
}
beforePolling := card.EnablePolling
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if beforePolling != enablePolling {
result := tx.Model(&model.IotCard{}).Where("id = ? AND enable_polling = ?", card.ID, beforePolling).Update("enable_polling", enablePolling)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新 IoT 卡轮询状态失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeConflict, "轮询状态已变更,请刷新后重试")
}
}
return s.appendPollingStatusAudit(ctx, tx, card, beforePolling, enablePolling)
})
if err != nil {
s.recordPollingStatusFailure(ctx, constants.AuditActionIotCardPollingStatusUpdated, constants.AuditResultFailed, card, enablePolling, err)
return err
}
card.EnablePolling = enablePolling
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)
}
}
return nil
}
// BatchUpdatePollingStatus 批量更新卡的轮询状态
func (s *Service) BatchUpdatePollingStatus(ctx context.Context, cardIDs []uint, enablePolling bool) error {
if len(cardIDs) == 0 {
return nil
}
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
if err != nil {
return err
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if updateErr := tx.Model(&model.IotCard{}).Where("id IN ?", cardIDs).Update("enable_polling", enablePolling).Error; updateErr != nil {
return errors.Wrap(errors.CodeDatabaseError, updateErr, "批量更新 IoT 卡轮询状态失败")
}
return s.appendBatchPollingStatusAudit(ctx, tx, cards, enablePolling)
})
if err != nil {
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)
}
}
}
return nil
}
// DeleteCard 删除卡(软删除)
func (s *Service) DeleteCard(ctx context.Context, cardID uint) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "IoT 卡统一审计接缝未配置")
}
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
denyErr := errors.New(errors.CodeNotFound, "IoT卡不存在")
s.recordCardLifecycleFailure(ctx, constants.AuditActionIotCardDeleted, "删除 IoT 卡被拒绝", constants.AuditResultDenied, nil, cardID, denyErr)
return denyErr
}
s.recordCardLifecycleFailure(ctx, constants.AuditActionIotCardDeleted, "删除 IoT 卡失败", constants.AuditResultFailed, nil, cardID, err)
return err
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if txErr := postgres.NewIotCardStore(tx, nil).Delete(ctx, cardID); txErr != nil {
return txErr
}
return s.appendCardLifecycleAudit(
ctx, tx, constants.AuditActionIotCardDeleted, "删除 IoT 卡", constants.AuditResultSuccess,
card, cardSnapshot(card), map[string]any{"deleted": true}, nil,
)
})
if err != nil {
s.recordCardLifecycleFailure(ctx, constants.AuditActionIotCardDeleted, "删除 IoT 卡失败", constants.AuditResultFailed, card, cardID, err)
return err
}
if s.assetIdentifierStore != nil {
_ = s.assetIdentifierStore.DeleteByAsset(ctx, model.AssetTypeIotCard, cardID)
}
s.iotCardStore.InvalidateListCountCache(ctx)
s.logger.Info("删除卡", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID))
if s.pollingCallback != nil {
s.pollingCallback.OnCardDeleted(ctx, cardID)
}
return nil
}
// BatchDeleteCards 批量删除卡(软删除)
func (s *Service) BatchDeleteCards(ctx context.Context, cardIDs []uint) error {
if len(cardIDs) == 0 {
return nil
}
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "IoT 卡统一审计接缝未配置")
}
_, queryErr := s.iotCardStore.GetByIDs(ctx, cardIDs)
if queryErr != nil {
s.recordBatchDeleteFailure(ctx, cardIDs, queryErr)
return queryErr
}
actualCards := make([]*model.IotCard, 0, len(cardIDs))
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if txErr := tx.WithContext(ctx).Where("id IN ?", cardIDs).Find(&actualCards).Error; txErr != nil {
return txErr
}
if txErr := postgres.NewIotCardStore(tx, nil).BatchDelete(ctx, cardIDs); txErr != nil {
return txErr
}
return s.appendBatchDeleteAudit(ctx, tx, actualCards, len(cardIDs))
})
if err != nil {
s.recordBatchDeleteFailure(ctx, cardIDs, err)
return err
}
s.iotCardStore.InvalidateListCountCache(ctx)
s.logger.Info("批量删除卡", zap.Int("count", len(cardIDs)))
// 通知轮询调度器
if s.pollingCallback != nil {
for _, cardID := range cardIDs {
s.pollingCallback.OnCardDeleted(ctx, cardID)
}
}
return nil
}
// UpdateRealnamePolicy 更新卡的实名认证策略
func (s *Service) UpdateRealnamePolicy(ctx context.Context, cardID uint, realnamePolicy string) error {
var card model.IotCard
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", cardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "IoT卡不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
changed := card.RealnamePolicy != realnamePolicy
if changed {
if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).Update("realname_policy", realnamePolicy).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新实名认证策略失败")
}
}
summary := "更新 IoT 卡实名策略"
if !changed {
summary = "确认 IoT 卡实名策略无需变化"
}
return s.appendCardLifecycleAudit(ctx, tx,
constants.AuditActionIotCardRealnamePolicyUpdated, summary, constants.AuditResultSuccess,
&card, map[string]any{"realname_policy": card.RealnamePolicy},
map[string]any{"realname_policy": realnamePolicy, "status_changed": changed}, nil)
})
if err != nil {
if card.ID > 0 {
s.recordCardLifecycleFailure(ctx, constants.AuditActionIotCardRealnamePolicyUpdated,
"更新 IoT 卡实名策略失败", constants.AuditResultFailed, &card, cardID, err)
}
return err
}
s.logger.Info("更新卡实名认证策略",
zap.Uint("card_id", cardID),
zap.String("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 {
return nil, errors.New(errors.CodeInvalidParam, "无效的实名状态")
}
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "IoT卡不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
oldStatus := card.RealNameStatus
now := time.Now()
if s.cardObservation == nil {
return nil, errors.New(errors.CodeInternalError, "卡实名观测能力未配置")
}
requestID := requestIDFromContext(ctx)
if _, err := s.cardObservation.ApplyCardObservation(ctx, carddomain.RealnameObservation{
CardID: cardID,
Verified: realNameStatus == constants.RealNameStatusVerified,
Metadata: carddomain.ObservationMetadata{
ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualOverride,
Scene: constants.CardObservationSceneManualRefresh, ObservedAt: now,
RequestID: requestID, CorrelationID: requestID,
},
}); err != nil {
wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡实名状态失败")
s.recordCardLifecycleFailure(ctx, constants.AuditActionIotCardRealnameStatusUpdated,
"人工更新 IoT 卡实名状态失败", constants.AuditResultFailed, card, cardID, wrapErr)
return nil, wrapErr
}
freshCard, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询更新后的IoT卡失败")
}
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)))
return freshCard, nil
}
func requestIDFromContext(ctx context.Context) string {
requestID := middleware.GetRequestIDFromContext(ctx)
if requestID != nil && *requestID != "" {
return *requestID
}
return auditcontext.From(ctx).RequestID
}
// 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))
}
}