Files
junhong_cmp_fiber/internal/service/iot_card/service.go
huang fba8e9e76b
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m18s
refactor(account): 移除卡类型字段、优化账号列表查询和权限检查
- 移除 IoT 卡和号卡的 card_type 字段(数据库迁移)
- 优化账号列表查询,支持按店铺和企业筛选
- 账号响应增加店铺名称和企业名称字段
- 实现批量加载店铺和企业名称,避免 N+1 查询
- 更新权限检查中间件,完善权限验证逻辑
- 更新相关测试用例,确保功能正确性
2026-02-03 10:59:44 +08:00

710 lines
20 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"
"go.uber.org/zap"
"gorm.io/gorm"
)
type Service struct {
db *gorm.DB
iotCardStore *postgres.IotCardStore
shopStore *postgres.ShopStore
assetAllocationRecordStore *postgres.AssetAllocationRecordStore
seriesAllocationStore *postgres.ShopSeriesAllocationStore
packageSeriesStore *postgres.PackageSeriesStore
gatewayClient *gateway.Client
logger *zap.Logger
}
func New(
db *gorm.DB,
iotCardStore *postgres.IotCardStore,
shopStore *postgres.ShopStore,
assetAllocationRecordStore *postgres.AssetAllocationRecordStore,
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
packageSeriesStore *postgres.PackageSeriesStore,
gatewayClient *gateway.Client,
logger *zap.Logger,
) *Service {
return &Service{
db: db,
iotCardStore: iotCardStore,
shopStore: shopStore,
assetAllocationRecordStore: assetAllocationRecordStore,
seriesAllocationStore: seriesAllocationStore,
packageSeriesStore: packageSeriesStore,
gatewayClient: gatewayClient,
logger: logger,
}
}
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
}
cards, total, err := s.iotCardStore.ListStandalone(ctx, opts, filters)
if err != nil {
return nil, err
}
shopMap := s.loadShopNames(ctx, cards)
list := make([]*dto.StandaloneIotCardResponse, 0, len(cards))
for _, card := range cards {
item := s.toStandaloneResponse(card, shopMap)
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})
standaloneResp := s.toStandaloneResponse(card, shopMap)
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
s.db.WithContext(ctx).Where("id IN ?", shopIDs).Find(&shops)
for _, shop := range shops {
shopMap[shop.ID] = shop.ShopName
}
}
return shopMap
}
func (s *Service) toStandaloneResponse(card *model.IotCard, shopMap 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,
CostPrice: card.CostPrice,
DistributePrice: card.DistributePrice,
Status: card.Status,
ShopID: card.ShopID,
ActivatedAt: card.ActivatedAt,
ActivationStatus: card.ActivationStatus,
RealNameStatus: card.RealNameStatus,
NetworkStatus: card.NetworkStatus,
DataUsageMB: card.DataUsageMB,
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]
}
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
}
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) {
if err := s.validateDirectSubordinate(ctx, operatorShopID, req.FromShopID); err != nil {
return nil, err
}
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
}
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
}
for _, card := range cards {
if boundCardIDSet[card.ID] {
failedItems = append(failedItems, dto.AllocationFailedItem{
ICCID: card.ICCID,
Reason: "已绑定设备的卡不能单独回收",
})
continue
}
if card.ShopID == nil || *card.ShopID != req.FromShopID {
failedItems = append(failedItems, dto.AllocationFailedItem{
ICCID: card.ICCID,
Reason: "卡不属于指定的店铺",
})
continue
}
cardIDs = append(cardIDs, card.ID)
}
if len(cardIDs) == 0 {
return &dto.RecallStandaloneCardsResponse{
TotalCount: len(cards),
SuccessCount: 0,
FailCount: len(failedItems),
FailedItems: failedItems,
}, nil
}
isPlatform := operatorShopID == nil
var newShopID *uint
var newStatus int
if isPlatform {
newShopID = nil
newStatus = constants.IotCardStatusInStock
} else {
newShopID = operatorShopID
newStatus = constants.IotCardStatusDistributed
}
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
}
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall)
records := s.buildRecallRecords(cards, cardIDs, req.FromShopID, operatorShopID, operatorID, allocationNo, req.Remark)
return txRecordStore.BatchCreate(ctx, records)
})
if err != nil {
return nil, err
}
return &dto.RecallStandaloneCardsResponse{
TotalCount: len(cards),
SuccessCount: len(cardIDs),
FailCount: len(failedItems),
AllocationNo: s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall),
FailedItems: failedItems,
}, nil
}
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) {
fromShopID := req.FromShopID
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, &fromShopID)
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.GetStandaloneByFilters(ctx, filters, &fromShopID)
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(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.AssetAllocationTypeRecall,
AssetType: constants.AssetTypeIotCard,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
FromOwnerType: constants.OwnerTypeShop,
FromOwnerID: &fromShopID,
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 {
allocation, err := s.seriesAllocationStore.GetByShopAndSeries(ctx, *operatorShopID, req.SeriesID)
if err != nil {
if err == gorm.ErrRecordNotFound || allocation.Status != 1 {
failedItems = append(failedItems, dto.CardSeriesBindngFailedItem{
ICCID: iccid,
Reason: "您没有权限分配该套餐系列",
})
continue
}
return nil, err
}
}
// 验证卡权限(基于 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 {
card.Status = newStatus
if err := s.iotCardStore.Update(ctx, card); err != nil {
return err
}
s.logger.Info("同步卡状态成功",
zap.String("iccid", iccid),
zap.Int("oldStatus", card.Status),
zap.Int("newStatus", newStatus),
)
}
return nil
}