From 1350a9ed79de5515c7be0e9f4ae0728b7c574138 Mon Sep 17 00:00:00 2001 From: huang Date: Mon, 11 May 2026 14:54:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=B9=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/model/dto/device_dto.go | 19 +- internal/model/dto/iot_card_dto.go | 22 +- internal/routes/device.go | 2 +- internal/routes/iot_card.go | 2 +- internal/service/device/service.go | 352 ++++++++++++++++------ internal/service/iot_card/service.go | 345 ++++++++++++++++----- internal/store/postgres/device_store.go | 67 ++-- internal/store/postgres/iot_card_store.go | 14 + 8 files changed, 613 insertions(+), 210 deletions(-) diff --git a/internal/model/dto/device_dto.go b/internal/model/dto/device_dto.go index 0b42bb4..fa00c6e 100644 --- a/internal/model/dto/device_dto.go +++ b/internal/model/dto/device_dto.go @@ -145,8 +145,23 @@ type RecallDevicesResponse struct { // BatchSetDeviceSeriesBindngRequest 批量设置设备的套餐系列绑定请求 type BatchSetDeviceSeriesBindngRequest struct { - DeviceIDs []uint `json:"device_ids" validate:"required,min=1,max=500,dive,required" required:"true" minItems:"1" maxItems:"500" description:"设备ID列表"` - SeriesID uint `json:"series_id" validate:"required,min=0" required:"true" minimum:"0" description:"套餐系列ID(0表示清除关联)"` + SelectionType string `json:"selection_type" validate:"omitempty,oneof=list range filter" enum:"list,range,filter" description:"选设备方式 (list:设备ID列表, range:设备虚拟号范围, filter:筛选条件;不传时兼容旧版设备ID列表)"` + DeviceIDs []uint `json:"device_ids" validate:"omitempty,max=1000,dive,required" maxItems:"1000" description:"设备ID列表(selection_type=list时使用,最多1000个)"` + VirtualNoStart string `json:"virtual_no_start" validate:"omitempty,max=100" maxLength:"100" description:"起始设备虚拟号(selection_type=range时使用)"` + VirtualNoEnd string `json:"virtual_no_end" validate:"omitempty,max=100" maxLength:"100" description:"结束设备虚拟号(selection_type=range时使用)"` + VirtualNo string `json:"virtual_no" validate:"omitempty,max=100" maxLength:"100" description:"设备虚拟号模糊查询(selection_type=filter时可选)"` + DeviceName string `json:"device_name" validate:"omitempty,max=255" maxLength:"255" description:"设备名称模糊查询(selection_type=filter时可选)"` + Status *int `json:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)(selection_type=filter时可选)"` + ShopID *uint `json:"shop_id" description:"店铺ID(selection_type=filter时可选,兼容旧参数,单选)"` + ShopIDs []uint `json:"shop_ids" validate:"omitempty,dive,min=1" description:"店铺ID列表(selection_type=filter时可选,多选)"` + FilterSeriesID *uint `json:"filter_series_id" description:"当前绑定套餐系列ID(selection_type=filter时可选;目标套餐系列仍使用series_id)"` + BatchNo string `json:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号(selection_type=filter时可选)"` + DeviceType string `json:"device_type" validate:"omitempty,max=50" maxLength:"50" description:"设备类型(selection_type=filter时可选)"` + Manufacturer string `json:"manufacturer" validate:"omitempty,max=255" maxLength:"255" description:"制造商模糊查询(selection_type=filter时可选)"` + CreatedAtStart *time.Time `json:"created_at_start" description:"创建时间起始(selection_type=filter时可选)"` + CreatedAtEnd *time.Time `json:"created_at_end" description:"创建时间结束(selection_type=filter时可选)"` + + SeriesID uint `json:"series_id" validate:"required,min=0" required:"true" minimum:"0" description:"目标套餐系列ID(0表示清除关联)"` } // DeviceSeriesBindngFailedItem 设备系列绑定失败项 diff --git a/internal/model/dto/iot_card_dto.go b/internal/model/dto/iot_card_dto.go index 0b3a7ad..dddefc0 100644 --- a/internal/model/dto/iot_card_dto.go +++ b/internal/model/dto/iot_card_dto.go @@ -148,8 +148,26 @@ type IotCardDetailResponse struct { // BatchSetCardSeriesBindngRequest 批量设置卡的套餐系列绑定请求 type BatchSetCardSeriesBindngRequest struct { - ICCIDs []string `json:"iccids" validate:"required,min=1,max=500,dive,required" required:"true" minItems:"1" maxItems:"500" description:"ICCID列表"` - SeriesID uint `json:"series_id" validate:"required,min=0" required:"true" minimum:"0" description:"套餐系列ID(0表示清除关联)"` + SelectionType string `json:"selection_type" validate:"omitempty,oneof=list range filter" enum:"list,range,filter" description:"选卡方式 (list:ICCID列表, range:号段范围, filter:筛选条件;不传时兼容旧版ICCID列表)"` + ICCIDs []string `json:"iccids" validate:"omitempty,max=1000,dive,required,max=20" maxItems:"1000" description:"ICCID列表(selection_type=list时使用,最多1000个)"` + ICCIDStart string `json:"iccid_start" validate:"omitempty,max=20" maxLength:"20" description:"起始ICCID(selection_type=range时使用)"` + ICCIDEnd string `json:"iccid_end" validate:"omitempty,max=20" maxLength:"20" description:"结束ICCID(selection_type=range时使用)"` + + Status *int `json:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)(selection_type=filter时可选)"` + CarrierID *uint `json:"carrier_id" description:"运营商ID(selection_type=filter时可选)"` + ShopID *uint `json:"shop_id" description:"分销商ID(selection_type=filter时可选,兼容旧参数,单选)"` + ShopIDs []uint `json:"shop_ids" validate:"omitempty,dive,min=1" description:"分销商ID列表(selection_type=filter时可选,多选)"` + FilterSeriesID *uint `json:"filter_series_id" description:"当前绑定套餐系列ID(selection_type=filter时可选;目标套餐系列仍使用series_id)"` + ICCID string `json:"iccid" validate:"omitempty,max=20" maxLength:"20" description:"ICCID模糊查询(selection_type=filter时可选)"` + MSISDN string `json:"msisdn" validate:"omitempty,max=20" maxLength:"20" description:"卡接入号模糊查询(selection_type=filter时可选)"` + IsStandalone *bool `json:"is_standalone" description:"是否为独立卡(selection_type=filter时可选)"` + BatchNo string `json:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号(selection_type=filter时可选)"` + PackageID *uint `json:"package_id" description:"套餐ID(selection_type=filter时可选)"` + IsDistributed *bool `json:"is_distributed" description:"是否已分销(selection_type=filter时可选)"` + IsReplaced *bool `json:"is_replaced" description:"是否有换卡记录(selection_type=filter时可选)"` + CarrierName string `json:"carrier_name" validate:"omitempty,max=100" maxLength:"100" description:"运营商名称模糊查询(selection_type=filter时可选)"` + + SeriesID uint `json:"series_id" validate:"required,min=0" required:"true" minimum:"0" description:"目标套餐系列ID(0表示清除关联)"` } // CardSeriesBindngFailedItem 卡系列绑定失败项 diff --git a/internal/routes/device.go b/internal/routes/device.go index 1b9bc82..236f914 100644 --- a/internal/routes/device.go +++ b/internal/routes/device.go @@ -126,7 +126,7 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp Register(devices, doc, groupPath, "PATCH", "/series-binding", handler.BatchSetSeriesBinding, RouteSpec{ Summary: "批量设置设备的套餐系列绑定", - Description: "批量设置或清除设备与套餐系列分配的关联关系。参数:series_id(套餐系列ID,0表示清除关联)。", + Description: "批量设置或清除设备与套餐系列分配的关联关系。series_id 为目标套餐系列ID,0表示清除关联。支持 selection_type=list/range/filter:list 传 device_ids(兼容旧请求,不传 selection_type 时默认按 device_ids 处理),range 传 virtual_no_start/virtual_no_end,filter 按筛选条件选择完整结果,不受列表分页限制。", Tags: []string{"设备管理"}, Input: new(dto.BatchSetDeviceSeriesBindngRequest), Output: new(dto.BatchSetDeviceSeriesBindngResponse), diff --git a/internal/routes/iot_card.go b/internal/routes/iot_card.go index e20ebe4..20eaca8 100644 --- a/internal/routes/iot_card.go +++ b/internal/routes/iot_card.go @@ -103,7 +103,7 @@ func registerIotCardRoutes(router fiber.Router, handler *admin.IotCardHandler, i Register(iotCards, doc, groupPath, "PATCH", "/series-binding", handler.BatchSetSeriesBinding, RouteSpec{ Summary: "批量设置卡的套餐系列绑定", - Description: "批量设置或清除卡与套餐系列分配的关联关系。参数:series_id(套餐系列ID,0表示清除关联)。", + Description: "批量设置或清除卡与套餐系列分配的关联关系。series_id 为目标套餐系列ID,0表示清除关联。支持 selection_type=list/range/filter:list 传 iccids(兼容旧请求,不传 selection_type 时默认按 iccids 处理),range 传 iccid_start/iccid_end,filter 按筛选条件选择完整结果,不受列表分页限制。", Tags: []string{"IoT卡管理"}, Input: new(dto.BatchSetCardSeriesBindngRequest), Output: new(dto.BatchSetCardSeriesBindngResponse), diff --git a/internal/service/device/service.go b/internal/service/device/service.go index 7715f05..6aedcac 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -1021,7 +1021,14 @@ func (s *Service) buildRecallRecords(devices []*model.Device, successDeviceIDs [ // BatchSetSeriesBinding 批量设置设备的套餐系列绑定 func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDeviceSeriesBindngRequest, operatorShopID *uint) (*dto.BatchSetDeviceSeriesBindngResponse, error) { - devices, err := s.deviceStore.GetByIDs(ctx, req.DeviceIDs) + selectionType, err := normalizeDeviceSeriesBindingSelection(req) + if err != nil { + return nil, err + } + + devices, err := s.getDevicesForSeriesBinding(ctx, req, selectionType) + batchTotal := deviceSeriesBindingBatchTotal(req, selectionType, devices) + auditData := deviceSeriesBindingAuditData(req, selectionType, operatorShopID) if err != nil { s.logDeviceOperation( ctx, @@ -1030,41 +1037,37 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe constants.AssetAuditResultFailed, nil, nil, - map[string]any{ - "series_id": req.SeriesID, - "device_ids": req.DeviceIDs, - "operator_shop_id": operatorShopID, - }, - len(req.DeviceIDs), + auditData, + batchTotal, 0, - len(req.DeviceIDs), + batchTotal, err, ) return nil, err } if len(devices) == 0 { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceSeriesBinding, - "设备系列绑定被拒绝", - constants.AssetAuditResultDenied, - nil, - nil, - map[string]any{ - "series_id": req.SeriesID, - "device_ids": req.DeviceIDs, - "reason": "设备不存在", - }, - len(req.DeviceIDs), - 0, - len(req.DeviceIDs), - errors.New(errors.CodeNotFound, "设备不存在"), - ) + failedItems := []dto.DeviceSeriesBindngFailedItem{} + if selectionType == dto.SelectionTypeList { + failedItems = s.buildDeviceNotFoundFailedItems(req.DeviceIDs) + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSeriesBinding, + "设备系列绑定被拒绝", + constants.AssetAuditResultDenied, + nil, + nil, + auditData, + batchTotal, + 0, + len(failedItems), + errors.New(errors.CodeNotFound, "设备不存在"), + ) + } return &dto.BatchSetDeviceSeriesBindngResponse{ SuccessCount: 0, - FailCount: len(req.DeviceIDs), - FailedItems: s.buildDeviceNotFoundFailedItems(req.DeviceIDs), + FailCount: len(failedItems), + FailedItems: failedItems, }, nil } @@ -1087,13 +1090,10 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe constants.AssetAuditResultDenied, nil, nil, - map[string]any{ - "series_id": req.SeriesID, - "device_ids": req.DeviceIDs, - }, - len(req.DeviceIDs), + auditData, + batchTotal, 0, - len(req.DeviceIDs), + batchTotal, appErr, ) return nil, appErr @@ -1105,13 +1105,10 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe constants.AssetAuditResultFailed, nil, nil, - map[string]any{ - "series_id": req.SeriesID, - "device_ids": req.DeviceIDs, - }, - len(req.DeviceIDs), + auditData, + batchTotal, 0, - len(req.DeviceIDs), + batchTotal, err, ) return nil, err @@ -1125,13 +1122,10 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe constants.AssetAuditResultDenied, nil, nil, - map[string]any{ - "series_id": req.SeriesID, - "device_ids": req.DeviceIDs, - }, - len(req.DeviceIDs), + auditData, + batchTotal, 0, - len(req.DeviceIDs), + batchTotal, appErr, ) return nil, appErr @@ -1140,72 +1134,75 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe var successDeviceIDs []uint var failedItems []dto.DeviceSeriesBindngFailedItem + successDeviceIDSet := make(map[uint]struct{}) - for _, deviceID := range req.DeviceIDs { - device, exists := deviceMap[deviceID] - if !exists { + hasSeriesAllocation := true + if operatorShopID != nil && req.SeriesID > 0 { + hasSeriesAllocation, err = s.hasAvailableSeriesAllocation(ctx, *operatorShopID, req.SeriesID) + if err != nil { + s.logDeviceOperation( + ctx, + constants.AssetAuditOpDeviceSeriesBinding, + "设备系列绑定失败", + constants.AssetAuditResultFailed, + nil, + nil, + auditData, + batchTotal, + 0, + batchTotal, + err, + ) + return nil, err + } + } + + addDevice := func(device *model.Device, requestedDeviceID uint) { + if device == nil { failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{ - DeviceID: deviceID, + DeviceID: requestedDeviceID, VirtualNo: "", Reason: "设备不存在", }) - continue + return } - // 验证操作者权限(仅代理用户)- 检查是否有该系列的套餐分配 - if operatorShopID != nil && req.SeriesID > 0 { - seriesAllocations, err := s.shopSeriesAllocationStore.GetByShopID(ctx, *operatorShopID) - if err != nil { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceSeriesBinding, - "设备系列绑定失败", - constants.AssetAuditResultFailed, - nil, - nil, - map[string]any{ - "series_id": req.SeriesID, - "device_ids": req.DeviceIDs, - }, - len(req.DeviceIDs), - 0, - len(req.DeviceIDs), - err, - ) - 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.DeviceSeriesBindngFailedItem{ - DeviceID: deviceID, - VirtualNo: device.VirtualNo, - Reason: "您没有权限分配该套餐系列", - }) - continue - } + if !hasSeriesAllocation { + failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{ + DeviceID: device.ID, + VirtualNo: device.VirtualNo, + Reason: "您没有权限分配该套餐系列", + }) + return } - // 验证设备权限(基于 device.ShopID) - if operatorShopID != nil { - if device.ShopID == nil || *device.ShopID != *operatorShopID { - failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{ - DeviceID: device.ID, - VirtualNo: device.VirtualNo, - Reason: "无权操作此设备", - }) - continue - } + // 代理只能操作自己店铺名下的设备,保持旧接口权限语义不变。 + if operatorShopID != nil && (device.ShopID == nil || *device.ShopID != *operatorShopID) { + failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{ + DeviceID: device.ID, + VirtualNo: device.VirtualNo, + Reason: "无权操作此设备", + }) + return } + if _, exists := successDeviceIDSet[device.ID]; exists { + return + } + successDeviceIDSet[device.ID] = struct{}{} successDeviceIDs = append(successDeviceIDs, device.ID) } + if selectionType == dto.SelectionTypeList { + for _, deviceID := range req.DeviceIDs { + addDevice(deviceMap[deviceID], deviceID) + } + } else { + for _, device := range devices { + addDevice(device, device.ID) + } + } + if len(successDeviceIDs) > 0 { var seriesIDPtr *uint if req.SeriesID > 0 { @@ -1221,10 +1218,11 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe nil, map[string]any{ "series_id": req.SeriesID, + "selection_type": selectionType, "success_device_ids": successDeviceIDs, "failed_items": failedItems, }, - len(req.DeviceIDs), + batchTotal, len(successDeviceIDs), len(failedItems), err, @@ -1253,11 +1251,12 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe "devices": beforeData, }, map[string]any{ + "selection_type": selectionType, "series_id": req.SeriesID, "success_device_ids": successDeviceIDs, "failed_items": failedItems, }, - len(req.DeviceIDs), + batchTotal, len(successDeviceIDs), len(failedItems), resultErr, @@ -1270,6 +1269,161 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe }, nil } +func normalizeDeviceSeriesBindingSelection(req *dto.BatchSetDeviceSeriesBindngRequest) (string, error) { + switch req.SelectionType { + case dto.SelectionTypeList: + if len(req.DeviceIDs) == 0 { + return "", errors.New(errors.CodeInvalidParam, "selection_type=list时device_ids不能为空") + } + return dto.SelectionTypeList, nil + case dto.SelectionTypeRange: + if req.VirtualNoStart == "" || req.VirtualNoEnd == "" { + return "", errors.New(errors.CodeInvalidParam, "selection_type=range时virtual_no_start和virtual_no_end不能为空") + } + if req.VirtualNoStart > req.VirtualNoEnd { + return "", errors.New(errors.CodeInvalidParam, "设备虚拟号起始值不能大于结束值") + } + return dto.SelectionTypeRange, nil + case dto.SelectionTypeFilter: + return dto.SelectionTypeFilter, nil + case "": + if len(req.DeviceIDs) > 0 { + return dto.SelectionTypeList, nil + } + if req.VirtualNoStart != "" || req.VirtualNoEnd != "" { + if req.VirtualNoStart == "" || req.VirtualNoEnd == "" { + return "", errors.New(errors.CodeInvalidParam, "virtual_no_start和virtual_no_end必须同时传入") + } + if req.VirtualNoStart > req.VirtualNoEnd { + return "", errors.New(errors.CodeInvalidParam, "设备虚拟号起始值不能大于结束值") + } + return dto.SelectionTypeRange, nil + } + if hasDeviceSeriesBindingFilters(req) { + return dto.SelectionTypeFilter, nil + } + return "", errors.New(errors.CodeInvalidParam, "请选择要设置套餐系列的设备") + default: + return "", errors.New(errors.CodeInvalidParam, "无效的选设备方式") + } +} + +func hasDeviceSeriesBindingFilters(req *dto.BatchSetDeviceSeriesBindngRequest) bool { + return req.VirtualNo != "" || + req.DeviceName != "" || + req.Status != nil || + req.ShopID != nil || + len(req.ShopIDs) > 0 || + req.FilterSeriesID != nil || + req.BatchNo != "" || + req.DeviceType != "" || + req.Manufacturer != "" || + req.CreatedAtStart != nil || + req.CreatedAtEnd != nil +} + +func (s *Service) getDevicesForSeriesBinding(ctx context.Context, req *dto.BatchSetDeviceSeriesBindngRequest, selectionType string) ([]*model.Device, error) { + switch selectionType { + case dto.SelectionTypeList: + return s.deviceStore.GetByIDs(ctx, req.DeviceIDs) + case dto.SelectionTypeRange: + return s.deviceStore.GetByFilters(ctx, map[string]any{ + "virtual_no_start": req.VirtualNoStart, + "virtual_no_end": req.VirtualNoEnd, + }) + case dto.SelectionTypeFilter: + return s.deviceStore.GetByFilters(ctx, buildDeviceSeriesBindingFilters(req)) + default: + return nil, errors.New(errors.CodeInvalidParam, "无效的选设备方式") + } +} + +func buildDeviceSeriesBindingFilters(req *dto.BatchSetDeviceSeriesBindngRequest) map[string]any { + filters := make(map[string]any) + if req.VirtualNo != "" { + filters["virtual_no"] = req.VirtualNo + } + if req.VirtualNoStart != "" { + filters["virtual_no_start"] = req.VirtualNoStart + } + if req.VirtualNoEnd != "" { + filters["virtual_no_end"] = req.VirtualNoEnd + } + if req.DeviceName != "" { + filters["device_name"] = req.DeviceName + } + if req.Status != nil { + filters["status"] = *req.Status + } + 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.BatchNo != "" { + filters["batch_no"] = req.BatchNo + } + if req.DeviceType != "" { + filters["device_type"] = req.DeviceType + } + if req.Manufacturer != "" { + filters["manufacturer"] = req.Manufacturer + } + if req.CreatedAtStart != nil { + filters["created_at_start"] = *req.CreatedAtStart + } + if req.CreatedAtEnd != nil { + filters["created_at_end"] = *req.CreatedAtEnd + } + 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 deviceSeriesBindingBatchTotal(req *dto.BatchSetDeviceSeriesBindngRequest, selectionType string, devices []*model.Device) int { + if selectionType == dto.SelectionTypeList { + return len(req.DeviceIDs) + } + return len(devices) +} + +func deviceSeriesBindingAuditData(req *dto.BatchSetDeviceSeriesBindngRequest, selectionType string, operatorShopID *uint) map[string]any { + data := map[string]any{ + "selection_type": selectionType, + "series_id": req.SeriesID, + "operator_shop_id": operatorShopID, + } + switch selectionType { + case dto.SelectionTypeList: + data["device_ids"] = req.DeviceIDs + case dto.SelectionTypeRange: + data["virtual_no_start"] = req.VirtualNoStart + data["virtual_no_end"] = req.VirtualNoEnd + case dto.SelectionTypeFilter: + data["filters"] = buildDeviceSeriesBindingFilters(req) + } + return data +} + func (s *Service) buildDeviceNotFoundFailedItems(deviceIDs []uint) []dto.DeviceSeriesBindngFailedItem { items := make([]dto.DeviceSeriesBindngFailedItem, len(deviceIDs)) for i, id := range deviceIDs { diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index 9044d95..713a23b 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -982,7 +982,14 @@ func (s *Service) buildRecallRecords(successCards []*model.IotCard, toShopID *ui // BatchSetSeriesBinding 批量设置卡的套餐系列绑定 func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCardSeriesBindngRequest, operatorShopID *uint) (*dto.BatchSetCardSeriesBindngResponse, error) { - cards, err := s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs) + 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{ @@ -991,44 +998,38 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCa ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, - BatchTotal: len(req.ICCIDs), - AfterData: map[string]any{ - "series_id": req.SeriesID, - "iccids": req.ICCIDs, - }, + BatchTotal: batchTotal, + AfterData: auditData, }) return nil, err } if len(cards) == 0 { - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardSeriesBinding, - OperationDesc: "卡系列绑定被拒绝", - ResultStatus: constants.AssetAuditResultDenied, - ErrorMsg: "卡不存在", - BatchTotal: len(req.ICCIDs), - FailCount: len(req.ICCIDs), - AfterData: map[string]any{ - "series_id": req.SeriesID, - "iccids": req.ICCIDs, - }, - }) + 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(req.ICCIDs), - FailedItems: s.buildCardNotFoundFailedItems(req.ICCIDs), + FailCount: len(failedItems), + FailedItems: failedItems, }, nil } - cardMap := make(map[string]*model.IotCard) - for _, card := range cards { - cardMap[card.ICCID] = card - } + cardMap := indexCardsByICCID(cards) // 验证系列存在(仅当 SeriesID > 0 时) - var packageSeries *model.PackageSeries if req.SeriesID > 0 { - packageSeries, err = s.packageSeriesStore.GetByID(ctx, req.SeriesID) + packageSeries, err := s.packageSeriesStore.GetByID(ctx, req.SeriesID) if err != nil { if err == gorm.ErrRecordNotFound { denyErr := errors.New(errors.CodeNotFound, "套餐系列不存在或已禁用") @@ -1039,11 +1040,8 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCa ResultStatus: constants.AssetAuditResultDenied, ErrorCode: errorCode, ErrorMsg: errorMsg, - BatchTotal: len(req.ICCIDs), - AfterData: map[string]any{ - "series_id": req.SeriesID, - "iccids": req.ICCIDs, - }, + BatchTotal: batchTotal, + AfterData: auditData, }) return nil, denyErr } @@ -1054,11 +1052,8 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCa ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, - BatchTotal: len(req.ICCIDs), - AfterData: map[string]any{ - "series_id": req.SeriesID, - "iccids": req.ICCIDs, - }, + BatchTotal: batchTotal, + AfterData: auditData, }) return nil, err } @@ -1071,11 +1066,8 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCa ResultStatus: constants.AssetAuditResultDenied, ErrorCode: errorCode, ErrorMsg: errorMsg, - BatchTotal: len(req.ICCIDs), - AfterData: map[string]any{ - "series_id": req.SeriesID, - "iccids": req.ICCIDs, - }, + BatchTotal: batchTotal, + AfterData: auditData, }) return nil, denyErr } @@ -1083,53 +1075,59 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCa var successCardIDs []uint var failedItems []dto.CardSeriesBindngFailedItem + successCardIDSet := make(map[uint]struct{}) - for _, iccid := range req.ICCIDs { - card, exists := cardMap[iccid] - if !exists { + 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: iccid, + ICCID: requestedICCID, Reason: "卡不存在", }) - continue + return } - // 验证操作者权限(仅代理用户) - 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 - } + if !hasSeriesAllocation { + failedItems = append(failedItems, dto.CardSeriesBindngFailedItem{ + ICCID: card.ICCID, + Reason: "您没有权限分配该套餐系列", + }) + return } - // 验证卡权限(基于 card.ShopID) - if operatorShopID != nil { - if card.ShopID == nil || *card.ShopID != *operatorShopID { - failedItems = append(failedItems, dto.CardSeriesBindngFailedItem{ - ICCID: iccid, - Reason: "无权操作此卡", - }) - continue - } + // 代理只能操作自己店铺名下的卡,保持旧接口权限语义不变。 + 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 { @@ -1143,13 +1141,14 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCa ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, - BatchTotal: len(req.ICCIDs), + BatchTotal: batchTotal, SuccessCount: len(successCardIDs), FailCount: len(failedItems), AfterData: map[string]any{ - "series_id": req.SeriesID, - "success_ids": successCardIDs, - "failed_items": failedItems, + "selection_type": selectionType, + "series_id": req.SeriesID, + "success_ids": successCardIDs, + "failed_items": failedItems, }, }) return nil, err @@ -1184,16 +1183,17 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCa OperationDesc: operationDesc, ResultStatus: resultStatus, ErrorMsg: errorMsg, - BatchTotal: len(req.ICCIDs), + BatchTotal: batchTotal, SuccessCount: len(successCardIDs), FailCount: len(failedItems), BeforeData: map[string]any{ "cards": beforeCards, }, AfterData: map[string]any{ - "series_id": req.SeriesID, - "success_ids": successCardIDs, - "failed_items": failedItems, + "selection_type": selectionType, + "series_id": req.SeriesID, + "success_ids": successCardIDs, + "failed_items": failedItems, }, }) @@ -1204,6 +1204,185 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCa }, 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 { diff --git a/internal/store/postgres/device_store.go b/internal/store/postgres/device_store.go index 7885ac0..7188f4a 100644 --- a/internal/store/postgres/device_store.go +++ b/internal/store/postgres/device_store.go @@ -99,10 +99,44 @@ func (s *DeviceStore) List(ctx context.Context, opts *store.QueryOptions, filter query := s.db.WithContext(ctx).Model(&model.Device{}) // 应用数据权限过滤(NULL shop_id 对代理用户不可见) query = middleware.ApplyShopFilter(ctx, query) + query = s.applyDeviceFilters(query, filters) + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + if opts == nil { + opts = &store.QueryOptions{ + Page: 1, + PageSize: constants.DefaultPageSize, + } + } + offset := (opts.Page - 1) * opts.PageSize + query = query.Offset(offset).Limit(opts.PageSize) + + if opts.OrderBy != "" { + query = query.Order(opts.OrderBy) + } else { + query = query.Order("created_at DESC") + } + + if err := query.Find(&devices).Error; err != nil { + return nil, 0, err + } + + return devices, total, nil +} + +func (s *DeviceStore) applyDeviceFilters(query *gorm.DB, filters map[string]any) *gorm.DB { if virtualNo, ok := filters["virtual_no"].(string); ok && virtualNo != "" { query = query.Where("virtual_no LIKE ?", "%"+virtualNo+"%") } + if virtualNoStart, ok := filters["virtual_no_start"].(string); ok && virtualNoStart != "" { + query = query.Where("virtual_no >= ?", virtualNoStart) + } + if virtualNoEnd, ok := filters["virtual_no_end"].(string); ok && virtualNoEnd != "" { + query = query.Where("virtual_no <= ?", virtualNoEnd) + } if deviceName, ok := filters["device_name"].(string); ok && deviceName != "" { query = query.Where("device_name LIKE ?", "%"+deviceName+"%") } @@ -140,31 +174,20 @@ func (s *DeviceStore) List(ctx context.Context, opts *store.QueryOptions, filter if seriesID, ok := filters["series_id"].(uint); ok && seriesID > 0 { query = query.Where("series_id = ?", seriesID) } + return query +} - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - - if opts == nil { - opts = &store.QueryOptions{ - Page: 1, - PageSize: constants.DefaultPageSize, - } - } - offset := (opts.Page - 1) * opts.PageSize - query = query.Offset(offset).Limit(opts.PageSize) - - if opts.OrderBy != "" { - query = query.Order(opts.OrderBy) - } else { - query = query.Order("created_at DESC") - } - +// GetByFilters 根据筛选条件查询设备 +// 用于批量设置套餐系列时选择完整筛选结果,不受列表分页限制。 +func (s *DeviceStore) GetByFilters(ctx context.Context, filters map[string]any) ([]*model.Device, error) { + var devices []*model.Device + query := s.db.WithContext(ctx).Model(&model.Device{}) + query = middleware.ApplyShopFilter(ctx, query) + query = s.applyDeviceFilters(query, filters) if err := query.Find(&devices).Error; err != nil { - return nil, 0, err + return nil, err } - - return devices, total, nil + return devices, nil } func (s *DeviceStore) UpdateShopID(ctx context.Context, id uint, shopID *uint) error { diff --git a/internal/store/postgres/iot_card_store.go b/internal/store/postgres/iot_card_store.go index 2f91b49..1a76e62 100644 --- a/internal/store/postgres/iot_card_store.go +++ b/internal/store/postgres/iot_card_store.go @@ -1036,6 +1036,20 @@ func (s *IotCardStore) GetDistributedStandaloneByFilters(ctx context.Context, fi return cards, nil } +// GetBySeriesBindingFilters 根据系列绑定筛选条件查询卡 +// 用于批量设置套餐系列时选择完整筛选结果,不受列表分页限制。 +func (s *IotCardStore) GetBySeriesBindingFilters(ctx context.Context, filters map[string]any) ([]*model.IotCard, error) { + query := s.db.WithContext(ctx).Model(&model.IotCard{}) + query = middleware.ApplyShopFilter(ctx, query) + query = s.applyStandaloneFilters(ctx, query, filters) + + var cards []*model.IotCard + if err := query.Find(&cards).Error; err != nil { + return nil, err + } + return cards, nil +} + func (s *IotCardStore) BatchUpdateShopIDAndStatus(ctx context.Context, cardIDs []uint, shopID *uint, status int) error { if len(cardIDs) == 0 { return nil