Files
junhong_cmp_fiber/internal/service/iot_card/service.go
break 3b7b856e48
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m9s
企业授权增强与资产列表扩展
- 企业卡授权唯一约束:新增 DB 迁移(000154),卡级部分唯一索引防止同一张卡被多个企业同时持有,Service 层新增跨企业冲突检测
- 单卡列表新增 network_status 过滤参数
- 单卡/设备列表新增 asset_status、asset_status_name、generation 响应字段
- 单卡/设备列表新增企业维度过滤(authorized_enterprise_id、is_authorized_to_enterprise)及响应中企业授权信息(批量加载,无 N+1)
- 主钱包流水/退款列表新增 asset_identifier 精确过滤参数
- 企业卡授权/收回接口升级为三模式(list/range/filter),企业设备授权/收回升级为双模式(list/filter)
- 升级 sonic v1.14.2 → v1.15.2 以兼容 Go 1.26

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 16:23:03 +09:00

2303 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"
"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))
}
}