Files
junhong_cmp_fiber/internal/service/iot_card/service.go
huang e661b59bb9
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m58s
feat: 实现订单超时自动取消功能,支持钱包余额解冻和 Asynq Scheduler 统一调度
- 新增 expires_at 字段和复合索引,待支付订单 30 分钟超时自动取消
- 实现 cancelOrder/unfreezeWalletForCancel 钱包余额解冻逻辑
- 创建 Asynq 定时任务(order_expire/alert_check/data_cleanup)
- 将原有 time.Ticker 轮询迁移至 Asynq Scheduler 统一调度
- 同步 delta specs 到 main specs 并归档变更
2026-02-28 17:16:15 +08:00

970 lines
27 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"
"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"
"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"
"go.uber.org/zap"
"gorm.io/gorm"
)
// 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)
}
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 // 轮询回调,可选
}
func New(
db *gorm.DB,
iotCardStore *postgres.IotCardStore,
shopStore *postgres.ShopStore,
assetAllocationRecordStore *postgres.AssetAllocationRecordStore,
shopPackageAllocationStore *postgres.ShopPackageAllocationStore,
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
packageSeriesStore *postgres.PackageSeriesStore,
gatewayClient *gateway.Client,
logger *zap.Logger,
) *Service {
return &Service{
db: db,
iotCardStore: iotCardStore,
shopStore: shopStore,
assetAllocationRecordStore: assetAllocationRecordStore,
shopPackageAllocationStore: shopPackageAllocationStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
packageSeriesStore: packageSeriesStore,
gatewayClient: gatewayClient,
logger: logger,
}
}
// SetPollingCallback 设置轮询回调
// 在应用启动时由 bootstrap 调用,注入轮询调度器
func (s *Service) SetPollingCallback(callback PollingCallback) {
s.pollingCallback = callback
}
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
}
if req.ShopID != nil {
filters["shop_id"] = *req.ShopID
}
if req.ICCID != "" {
filters["iccid"] = req.ICCID
}
if req.MSISDN != "" {
filters["msisdn"] = req.MSISDN
}
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
}
// 代理用户注入 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 && 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)
seriesMap := s.loadSeriesNames(ctx, cards)
list := make([]*dto.StandaloneIotCardResponse, 0, len(cards))
for _, card := range cards {
item := s.toStandaloneResponse(card, shopMap, seriesMap)
list = append(list, item)
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
return &dto.ListStandaloneIotCardResponse{
List: list,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}, nil
}
// 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,
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,
ShopID: card.ShopID,
ActivatedAt: card.ActivatedAt,
ActivationStatus: card.ActivationStatus,
RealNameStatus: card.RealNameStatus,
NetworkStatus: card.NetworkStatus,
DataUsageMB: card.DataUsageMB,
CurrentMonthUsageMB: card.CurrentMonthUsageMB,
CurrentMonthStartDate: card.CurrentMonthStartDate,
LastMonthTotalMB: card.LastMonthTotalMB,
LastDataCheckAt: card.LastDataCheckAt,
LastRealNameCheckAt: card.LastRealNameCheckAt,
EnablePolling: card.EnablePolling,
SeriesID: card.SeriesID,
FirstCommissionPaid: card.FirstCommissionPaid,
AccumulatedRecharge: card.AccumulatedRecharge,
CreatedAt: card.CreatedAt,
UpdatedAt: card.UpdatedAt,
}
if card.ShopID != nil && *card.ShopID > 0 {
resp.ShopName = shopMap[*card.ShopID]
}
if card.SeriesID != nil && *card.SeriesID > 0 {
resp.SeriesName = seriesMap[*card.SeriesID]
}
return resp
}
func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandaloneCardsRequest, operatorID uint, operatorShopID *uint) (*dto.AllocateStandaloneCardsResponse, error) {
if err := s.validateDirectSubordinate(ctx, operatorShopID, req.ToShopID); err != nil {
return nil, err
}
cards, err := s.getCardsForAllocation(ctx, req, operatorShopID)
if err != nil {
return nil, err
}
if len(cards) == 0 {
return &dto.AllocateStandaloneCardsResponse{
TotalCount: 0,
SuccessCount: 0,
FailCount: 0,
FailedItems: []dto.AllocationFailedItem{},
}, nil
}
var cardIDs []uint
var failedItems []dto.AllocationFailedItem
boundCardIDs, err := s.iotCardStore.GetBoundCardIDs(ctx, s.extractCardIDs(cards))
if err != nil {
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 {
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 {
return nil, err
}
// 通知轮询调度器状态变化(卡被分配后可能需要重新匹配配置)
if s.pollingCallback != nil && len(cardIDs) > 0 {
for _, cardID := range cardIDs {
s.pollingCallback.OnCardStatusChanged(ctx, cardID)
}
}
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 {
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 {
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 {
return nil, err
}
// 通知轮询调度器状态变化(卡被回收后可能需要重新匹配配置)
if s.pollingCallback != nil && len(cardIDs) > 0 {
for _, cardID := range cardIDs {
s.pollingCallback.OnCardStatusChanged(ctx, cardID)
}
}
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 {
if targetShop.ParentID != nil {
return errors.ErrNotDirectSubordinate
}
} else {
if targetShop.ParentID == nil || *targetShop.ParentID != *operatorShopID {
return errors.ErrNotDirectSubordinate
}
}
return nil
}
func (s *Service) getCardsForAllocation(ctx context.Context, req *dto.AllocateStandaloneCardsRequest, operatorShopID *uint) ([]*model.IotCard, error) {
switch req.SelectionType {
case dto.SelectionTypeList:
return s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs)
case dto.SelectionTypeRange:
return s.iotCardStore.GetStandaloneByICCIDRange(ctx, req.ICCIDStart, req.ICCIDEnd, operatorShopID)
case dto.SelectionTypeFilter:
filters := make(map[string]any)
if req.CarrierID != nil {
filters["carrier_id"] = *req.CarrierID
}
if req.BatchNo != "" {
filters["batch_no"] = req.BatchNo
}
if req.Status != nil {
filters["status"] = *req.Status
}
return s.iotCardStore.GetStandaloneByFilters(ctx, filters, operatorShopID)
default:
return nil, errors.New(errors.CodeInvalidParam, "无效的选卡方式")
}
}
func (s *Service) getCardsForRecall(ctx context.Context, req *dto.RecallStandaloneCardsRequest) ([]*model.IotCard, error) {
switch req.SelectionType {
case dto.SelectionTypeList:
return s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs)
case dto.SelectionTypeRange:
// 查询已分配给店铺的单卡(回收场景)
return s.iotCardStore.GetDistributedStandaloneByICCIDRange(ctx, req.ICCIDStart, req.ICCIDEnd)
case dto.SelectionTypeFilter:
filters := make(map[string]any)
if req.CarrierID != nil {
filters["carrier_id"] = *req.CarrierID
}
if req.BatchNo != "" {
filters["batch_no"] = req.BatchNo
}
// 查询已分配给店铺的单卡(回收场景)
return s.iotCardStore.GetDistributedStandaloneByFilters(ctx, filters)
default:
return nil, errors.New(errors.CodeInvalidParam, "无效的选卡方式")
}
}
func (s *Service) extractCardIDs(cards []*model.IotCard) []uint {
ids := make([]uint, len(cards))
for i, card := range cards {
ids[i] = card.ID
}
return ids
}
func (s *Service) buildAllocationRecords(cards []*model.IotCard, successCardIDs []uint, fromShopID *uint, toShopID uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord {
successIDSet := make(map[uint]bool)
for _, id := range successCardIDs {
successIDSet[id] = true
}
var records []*model.AssetAllocationRecord
for _, card := range cards {
if !successIDSet[card.ID] {
continue
}
record := &model.AssetAllocationRecord{
AllocationNo: allocationNo,
AllocationType: constants.AssetAllocationTypeAllocate,
AssetType: constants.AssetTypeIotCard,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
ToOwnerType: constants.OwnerTypeShop,
ToOwnerID: toShopID,
OperatorID: operatorID,
Remark: remark,
}
if fromShopID == nil {
record.FromOwnerType = constants.OwnerTypePlatform
record.FromOwnerID = nil
} else {
record.FromOwnerType = constants.OwnerTypeShop
record.FromOwnerID = fromShopID
}
records = append(records, record)
}
return records
}
func (s *Service) buildRecallRecords(successCards []*model.IotCard, toShopID *uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord {
var records []*model.AssetAllocationRecord
for _, card := range successCards {
record := &model.AssetAllocationRecord{
AllocationNo: allocationNo,
AllocationType: constants.AssetAllocationTypeRecall,
AssetType: constants.AssetTypeIotCard,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
FromOwnerType: constants.OwnerTypeShop,
FromOwnerID: card.ShopID, // 从卡的当前所属店铺获取
OperatorID: operatorID,
Remark: remark,
}
if toShopID == nil {
record.ToOwnerType = constants.OwnerTypePlatform
record.ToOwnerID = 0
} else {
record.ToOwnerType = constants.OwnerTypeShop
record.ToOwnerID = *toShopID
}
records = append(records, record)
}
return records
}
// BatchSetSeriesBinding 批量设置卡的套餐系列绑定
func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCardSeriesBindngRequest, operatorShopID *uint) (*dto.BatchSetCardSeriesBindngResponse, error) {
cards, err := s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs)
if err != nil {
return nil, err
}
if len(cards) == 0 {
return &dto.BatchSetCardSeriesBindngResponse{
SuccessCount: 0,
FailCount: len(req.ICCIDs),
FailedItems: s.buildCardNotFoundFailedItems(req.ICCIDs),
}, nil
}
cardMap := make(map[string]*model.IotCard)
for _, card := range cards {
cardMap[card.ICCID] = card
}
// 验证系列存在(仅当 SeriesID > 0 时)
var packageSeries *model.PackageSeries
if req.SeriesID > 0 {
packageSeries, err = s.packageSeriesStore.GetByID(ctx, req.SeriesID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "套餐系列不存在或已禁用")
}
return nil, err
}
if packageSeries.Status != 1 {
return nil, errors.New(errors.CodeInvalidParam, "套餐系列不存在或已禁用")
}
}
var successCardIDs []uint
var failedItems []dto.CardSeriesBindngFailedItem
for _, iccid := range req.ICCIDs {
card, exists := cardMap[iccid]
if !exists {
failedItems = append(failedItems, dto.CardSeriesBindngFailedItem{
ICCID: iccid,
Reason: "卡不存在",
})
continue
}
// 验证操作者权限(仅代理用户)
if operatorShopID != nil && req.SeriesID > 0 {
seriesAllocations, err := s.shopSeriesAllocationStore.GetByShopID(ctx, *operatorShopID)
if err != nil {
return nil, err
}
hasSeriesAllocation := false
for _, alloc := range seriesAllocations {
if alloc.SeriesID == req.SeriesID && alloc.Status == 1 {
hasSeriesAllocation = true
break
}
}
if !hasSeriesAllocation {
failedItems = append(failedItems, dto.CardSeriesBindngFailedItem{
ICCID: iccid,
Reason: "您没有权限分配该套餐系列",
})
continue
}
}
// 验证卡权限(基于 card.ShopID
if operatorShopID != nil {
if card.ShopID == nil || *card.ShopID != *operatorShopID {
failedItems = append(failedItems, dto.CardSeriesBindngFailedItem{
ICCID: iccid,
Reason: "无权操作此卡",
})
continue
}
}
successCardIDs = append(successCardIDs, card.ID)
}
if len(successCardIDs) > 0 {
var seriesIDPtr *uint
if req.SeriesID > 0 {
seriesIDPtr = &req.SeriesID
}
if err := s.iotCardStore.BatchUpdateSeriesID(ctx, successCardIDs, seriesIDPtr); err != nil {
return nil, err
}
}
return &dto.BatchSetCardSeriesBindngResponse{
SuccessCount: len(successCardIDs),
FailCount: len(failedItems),
FailedItems: failedItems,
}, nil
}
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
}
// SyncCardStatusFromGateway 从 Gateway 同步卡状态(示例方法)
func (s *Service) SyncCardStatusFromGateway(ctx context.Context, iccid string) error {
if s.gatewayClient == nil {
return errors.New(errors.CodeGatewayError, "Gateway 客户端未配置")
}
resp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{
CardNo: iccid,
})
if err != nil {
s.logger.Error("查询卡状态失败", zap.String("iccid", iccid), zap.Error(err))
return errors.Wrap(errors.CodeGatewayError, err, "查询卡状态失败")
}
card, err := s.iotCardStore.GetByICCID(ctx, iccid)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "IoT卡不存在")
}
return err
}
var newStatus int
switch resp.CardStatus {
case "准备":
newStatus = constants.IotCardStatusInStock
case "正常":
newStatus = constants.IotCardStatusDistributed
case "停机":
newStatus = constants.IotCardStatusSuspended
default:
s.logger.Warn("未知的卡状态", zap.String("cardStatus", resp.CardStatus))
return nil
}
if card.Status != newStatus {
oldStatus := card.Status
card.Status = newStatus
if err := s.iotCardStore.Update(ctx, card); err != nil {
return err
}
s.logger.Info("同步卡状态成功",
zap.String("iccid", iccid),
zap.Int("oldStatus", oldStatus),
zap.Int("newStatus", newStatus),
)
// 通知轮询调度器状态变化
if s.pollingCallback != nil {
s.pollingCallback.OnCardStatusChanged(ctx, card.ID)
}
}
return nil
}
// 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 {
return errors.New(errors.CodeNotFound, "IoT卡不存在")
}
return err
}
// 检查是否需要更新
if card.EnablePolling == enablePolling {
return nil // 状态未变化
}
// 更新数据库
card.EnablePolling = enablePolling
if err := s.iotCardStore.Update(ctx, card); err != nil {
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)
}
}
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 {
return err
}
s.logger.Info("批量更新卡轮询状态",
zap.Int("count", len(cardIDs)),
zap.Bool("enable_polling", enablePolling),
)
// 通知轮询调度器
if s.pollingCallback != nil {
for _, cardID := range cardIDs {
if enablePolling {
s.pollingCallback.OnCardEnabled(ctx, cardID)
} else {
s.pollingCallback.OnCardDisabled(ctx, cardID)
}
}
}
return nil
}
// DeleteCard 删除卡(软删除)
func (s *Service) DeleteCard(ctx context.Context, cardID uint) error {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "IoT卡不存在")
}
return err
}
// 执行软删除
if err := s.iotCardStore.Delete(ctx, cardID); err != nil {
return err
}
s.logger.Info("删除卡", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID))
// 通知轮询调度器
if s.pollingCallback != nil {
s.pollingCallback.OnCardDeleted(ctx, cardID)
}
return nil
}
// BatchDeleteCards 批量删除卡(软删除)
func (s *Service) BatchDeleteCards(ctx context.Context, cardIDs []uint) error {
if len(cardIDs) == 0 {
return nil
}
// 批量软删除
if err := s.iotCardStore.BatchDelete(ctx, cardIDs); err != nil {
return err
}
s.logger.Info("批量删除卡", zap.Int("count", len(cardIDs)))
// 通知轮询调度器
if s.pollingCallback != nil {
for _, cardID := range cardIDs {
s.pollingCallback.OnCardDeleted(ctx, cardID)
}
}
return nil
}