批量
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m53s

This commit is contained in:
2026-05-11 14:54:23 +08:00
parent 93200a9074
commit 1350a9ed79
8 changed files with 613 additions and 210 deletions

View File

@@ -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:"套餐系列ID0表示清除关联"`
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:"店铺IDselection_type=filter时可选兼容旧参数单选"`
ShopIDs []uint `json:"shop_ids" validate:"omitempty,dive,min=1" description:"店铺ID列表selection_type=filter时可选多选"`
FilterSeriesID *uint `json:"filter_series_id" description:"当前绑定套餐系列IDselection_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:"目标套餐系列ID0表示清除关联"`
}
// DeviceSeriesBindngFailedItem 设备系列绑定失败项

View File

@@ -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:"套餐系列ID0表示清除关联"`
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:"起始ICCIDselection_type=range时使用"`
ICCIDEnd string `json:"iccid_end" validate:"omitempty,max=20" maxLength:"20" description:"结束ICCIDselection_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:"运营商IDselection_type=filter时可选"`
ShopID *uint `json:"shop_id" description:"分销商IDselection_type=filter时可选兼容旧参数单选"`
ShopIDs []uint `json:"shop_ids" validate:"omitempty,dive,min=1" description:"分销商ID列表selection_type=filter时可选多选"`
FilterSeriesID *uint `json:"filter_series_id" description:"当前绑定套餐系列IDselection_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:"套餐IDselection_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:"目标套餐系列ID0表示清除关联"`
}
// CardSeriesBindngFailedItem 卡系列绑定失败项

View File

@@ -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套餐系列ID0表示清除关联。",
Description: "批量设置或清除设备与套餐系列分配的关联关系。series_id 为目标套餐系列ID0表示清除关联。支持 selection_type=list/range/filterlist 传 device_ids兼容旧请求不传 selection_type 时默认按 device_ids 处理range 传 virtual_no_start/virtual_no_endfilter 按筛选条件选择完整结果,不受列表分页限制。",
Tags: []string{"设备管理"},
Input: new(dto.BatchSetDeviceSeriesBindngRequest),
Output: new(dto.BatchSetDeviceSeriesBindngResponse),

View File

@@ -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套餐系列ID0表示清除关联。",
Description: "批量设置或清除卡与套餐系列分配的关联关系。series_id 为目标套餐系列ID0表示清除关联。支持 selection_type=list/range/filterlist 传 iccids兼容旧请求不传 selection_type 时默认按 iccids 处理range 传 iccid_start/iccid_endfilter 按筛选条件选择完整结果,不受列表分页限制。",
Tags: []string{"IoT卡管理"},
Input: new(dto.BatchSetCardSeriesBindngRequest),
Output: new(dto.BatchSetCardSeriesBindngResponse),

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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