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>
2303 lines
72 KiB
Go
2303 lines
72 KiB
Go
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))
|
||
}
|
||
}
|