Files
junhong_cmp_fiber/internal/service/device/service.go
huang efe8a362aa
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m4s
fix: 平台账号可回收所有店铺的卡和设备
之前平台用户回收时只能回收一级代理的资产,现在允许回收所有店铺的资产。

修改:
- iot_card/service.go: isDirectSubordinate 对平台用户返回 true
- device/service.go: RecallDevices 平台用户跳过直属下级验证

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 16:37:23 +08:00

735 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 device
import (
"context"
"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"
"gorm.io/gorm"
)
type Service struct {
db *gorm.DB
deviceStore *postgres.DeviceStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
iotCardStore *postgres.IotCardStore
shopStore *postgres.ShopStore
assetAllocationRecordStore *postgres.AssetAllocationRecordStore
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
packageSeriesStore *postgres.PackageSeriesStore
}
func New(
db *gorm.DB,
deviceStore *postgres.DeviceStore,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
iotCardStore *postgres.IotCardStore,
shopStore *postgres.ShopStore,
assetAllocationRecordStore *postgres.AssetAllocationRecordStore,
shopPackageAllocationStore *postgres.ShopPackageAllocationStore,
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
packageSeriesStore *postgres.PackageSeriesStore,
) *Service {
return &Service{
db: db,
deviceStore: deviceStore,
deviceSimBindingStore: deviceSimBindingStore,
iotCardStore: iotCardStore,
shopStore: shopStore,
assetAllocationRecordStore: assetAllocationRecordStore,
shopPackageAllocationStore: shopPackageAllocationStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
packageSeriesStore: packageSeriesStore,
}
}
// List 获取设备列表
func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.ListDeviceResponse, 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.DeviceNo != "" {
filters["device_no"] = req.DeviceNo
}
if req.DeviceName != "" {
filters["device_name"] = req.DeviceName
}
if req.Status != nil {
filters["status"] = *req.Status
}
if req.ShopID != nil {
filters["shop_id"] = req.ShopID
}
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
}
if req.SeriesID != nil {
filters["series_id"] = *req.SeriesID
}
devices, total, err := s.deviceStore.List(ctx, opts, filters)
if err != nil {
return nil, err
}
shopMap := s.loadShopData(ctx, devices)
seriesMap := s.loadSeriesNames(ctx, devices)
bindingCounts, err := s.getBindingCounts(ctx, s.extractDeviceIDs(devices))
if err != nil {
return nil, err
}
list := make([]*dto.DeviceResponse, 0, len(devices))
for _, device := range devices {
item := s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts)
list = append(list, item)
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
return &dto.ListDeviceResponse{
List: list,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}, nil
}
// Get 获取设备详情
func (s *Service) Get(ctx context.Context, id uint) (*dto.DeviceResponse, error) {
device, err := s.deviceStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "设备不存在")
}
return nil, err
}
shopMap := s.loadShopData(ctx, []*model.Device{device})
seriesMap := s.loadSeriesNames(ctx, []*model.Device{device})
bindingCounts, err := s.getBindingCounts(ctx, []uint{device.ID})
if err != nil {
return nil, err
}
return s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts), nil
}
// GetByDeviceNo 通过设备号获取设备详情
func (s *Service) GetByDeviceNo(ctx context.Context, deviceNo string) (*dto.DeviceResponse, error) {
device, err := s.deviceStore.GetByDeviceNo(ctx, deviceNo)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "设备不存在")
}
return nil, err
}
shopMap := s.loadShopData(ctx, []*model.Device{device})
seriesMap := s.loadSeriesNames(ctx, []*model.Device{device})
bindingCounts, err := s.getBindingCounts(ctx, []uint{device.ID})
if err != nil {
return nil, err
}
return s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts), nil
}
func (s *Service) Delete(ctx context.Context, id uint) error {
device, err := s.deviceStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "设备不存在")
}
return err
}
if err := s.deviceSimBindingStore.UnbindByDeviceID(ctx, device.ID); err != nil {
return err
}
return s.deviceStore.Delete(ctx, id)
}
// AllocateDevices 批量分配设备
func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesRequest, operatorID uint, operatorShopID *uint) (*dto.AllocateDevicesResponse, error) {
// 验证目标店铺是否为直属下级
if err := s.validateDirectSubordinate(ctx, operatorShopID, req.TargetShopID); err != nil {
return nil, err
}
devices, err := s.deviceStore.GetByIDs(ctx, req.DeviceIDs)
if err != nil {
return nil, err
}
if len(devices) == 0 {
return &dto.AllocateDevicesResponse{
SuccessCount: 0,
FailCount: 0,
FailedItems: []dto.AllocationDeviceFailedItem{},
}, nil
}
var deviceIDs []uint
var failedItems []dto.AllocationDeviceFailedItem
isPlatform := operatorShopID == nil
for _, device := range devices {
// 平台只能分配 shop_id=NULL 的设备
if isPlatform && device.ShopID != nil {
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
Reason: "平台只能分配库存设备",
})
continue
}
// 代理只能分配自己店铺的设备
if !isPlatform && (device.ShopID == nil || *device.ShopID != *operatorShopID) {
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
Reason: "设备不属于当前店铺",
})
continue
}
deviceIDs = append(deviceIDs, device.ID)
}
if len(deviceIDs) == 0 {
return &dto.AllocateDevicesResponse{
SuccessCount: 0,
FailCount: len(failedItems),
FailedItems: failedItems,
}, nil
}
newStatus := constants.DeviceStatusDistributed
targetShopID := req.TargetShopID
err = s.db.Transaction(func(tx *gorm.DB) error {
txDeviceStore := postgres.NewDeviceStore(tx, nil)
txCardStore := postgres.NewIotCardStore(tx, nil)
txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil)
if err := txDeviceStore.BatchUpdateShopIDAndStatus(ctx, deviceIDs, &targetShopID, newStatus); err != nil {
return err
}
boundCardIDs, err := s.deviceSimBindingStore.GetBoundCardIDsByDeviceIDs(ctx, deviceIDs)
if err != nil {
return err
}
if len(boundCardIDs) > 0 {
if err := txCardStore.BatchUpdateShopIDAndStatus(ctx, boundCardIDs, &targetShopID, constants.IotCardStatusDistributed); err != nil {
return err
}
}
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate)
records := s.buildAllocationRecords(devices, deviceIDs, operatorShopID, targetShopID, operatorID, allocationNo, req.Remark)
return txRecordStore.BatchCreate(ctx, records)
})
if err != nil {
return nil, err
}
return &dto.AllocateDevicesResponse{
SuccessCount: len(deviceIDs),
FailCount: len(failedItems),
FailedItems: failedItems,
}, nil
}
// RecallDevices 批量回收设备
func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesRequest, operatorID uint, operatorShopID *uint) (*dto.RecallDevicesResponse, error) {
devices, err := s.deviceStore.GetByIDs(ctx, req.DeviceIDs)
if err != nil {
return nil, err
}
if len(devices) == 0 {
return &dto.RecallDevicesResponse{
SuccessCount: 0,
FailCount: 0,
FailedItems: []dto.AllocationDeviceFailedItem{},
}, nil
}
var deviceIDs []uint
var failedItems []dto.AllocationDeviceFailedItem
isPlatform := operatorShopID == nil
for _, device := range devices {
// 验证设备所属店铺是否为直属下级
if device.ShopID == nil {
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
Reason: "设备已在平台库存中",
})
continue
}
// 验证直属下级关系(平台用户可以回收所有店铺的设备)
if !isPlatform {
if err := s.validateDirectSubordinate(ctx, operatorShopID, *device.ShopID); err != nil {
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
Reason: "只能回收直属下级店铺的设备",
})
continue
}
}
deviceIDs = append(deviceIDs, device.ID)
}
if len(deviceIDs) == 0 {
return &dto.RecallDevicesResponse{
SuccessCount: 0,
FailCount: len(failedItems),
FailedItems: failedItems,
}, nil
}
var newShopID *uint
var newStatus int
if isPlatform {
newShopID = nil
newStatus = constants.DeviceStatusInStock
} else {
newShopID = operatorShopID
newStatus = constants.DeviceStatusDistributed
}
err = s.db.Transaction(func(tx *gorm.DB) error {
txDeviceStore := postgres.NewDeviceStore(tx, nil)
txCardStore := postgres.NewIotCardStore(tx, nil)
txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil)
if err := txDeviceStore.BatchUpdateShopIDAndStatus(ctx, deviceIDs, newShopID, newStatus); err != nil {
return err
}
boundCardIDs, err := s.deviceSimBindingStore.GetBoundCardIDsByDeviceIDs(ctx, deviceIDs)
if err != nil {
return err
}
if len(boundCardIDs) > 0 {
var cardStatus int
if isPlatform {
cardStatus = constants.IotCardStatusInStock
} else {
cardStatus = constants.IotCardStatusDistributed
}
if err := txCardStore.BatchUpdateShopIDAndStatus(ctx, boundCardIDs, newShopID, cardStatus); err != nil {
return err
}
}
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall)
records := s.buildRecallRecords(devices, deviceIDs, operatorShopID, newShopID, operatorID, allocationNo, req.Remark)
return txRecordStore.BatchCreate(ctx, records)
})
if err != nil {
return nil, err
}
return &dto.RecallDevicesResponse{
SuccessCount: len(deviceIDs),
FailCount: len(failedItems),
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) loadShopData(ctx context.Context, devices []*model.Device) map[uint]string {
shopIDs := make([]uint, 0)
shopIDSet := make(map[uint]bool)
for _, device := range devices {
if device.ShopID != nil && *device.ShopID > 0 && !shopIDSet[*device.ShopID] {
shopIDs = append(shopIDs, *device.ShopID)
shopIDSet[*device.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, devices []*model.Device) map[uint]string {
seriesIDs := make([]uint, 0)
seriesIDSet := make(map[uint]bool)
for _, device := range devices {
if device.SeriesID != nil && *device.SeriesID > 0 && !seriesIDSet[*device.SeriesID] {
seriesIDs = append(seriesIDs, *device.SeriesID)
seriesIDSet[*device.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) getBindingCounts(ctx context.Context, deviceIDs []uint) (map[uint]int64, error) {
result := make(map[uint]int64)
if len(deviceIDs) == 0 {
return result, nil
}
bindings, err := s.deviceSimBindingStore.ListByDeviceIDs(ctx, deviceIDs)
if err != nil {
return nil, err
}
for _, binding := range bindings {
result[binding.DeviceID]++
}
return result, nil
}
func (s *Service) extractDeviceIDs(devices []*model.Device) []uint {
ids := make([]uint, len(devices))
for i, device := range devices {
ids[i] = device.ID
}
return ids
}
func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string, seriesMap map[uint]string, bindingCounts map[uint]int64) *dto.DeviceResponse {
resp := &dto.DeviceResponse{
ID: device.ID,
DeviceNo: device.DeviceNo,
DeviceName: device.DeviceName,
DeviceModel: device.DeviceModel,
DeviceType: device.DeviceType,
MaxSimSlots: device.MaxSimSlots,
Manufacturer: device.Manufacturer,
BatchNo: device.BatchNo,
ShopID: device.ShopID,
Status: device.Status,
StatusName: s.getDeviceStatusName(device.Status),
BoundCardCount: int(bindingCounts[device.ID]),
SeriesID: device.SeriesID,
FirstCommissionPaid: device.FirstCommissionPaid,
AccumulatedRecharge: device.AccumulatedRecharge,
ActivatedAt: device.ActivatedAt,
CreatedAt: device.CreatedAt,
UpdatedAt: device.UpdatedAt,
}
if device.ShopID != nil && *device.ShopID > 0 {
resp.ShopName = shopMap[*device.ShopID]
}
if device.SeriesID != nil && *device.SeriesID > 0 {
resp.SeriesName = seriesMap[*device.SeriesID]
}
return resp
}
func (s *Service) getDeviceStatusName(status int) string {
switch status {
case constants.DeviceStatusInStock:
return "在库"
case constants.DeviceStatusDistributed:
return "已分销"
case constants.DeviceStatusActivated:
return "已激活"
case constants.DeviceStatusSuspended:
return "已停用"
default:
return "未知"
}
}
func (s *Service) buildAllocationRecords(devices []*model.Device, successDeviceIDs []uint, fromShopID *uint, toShopID uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord {
successIDSet := make(map[uint]bool)
for _, id := range successDeviceIDs {
successIDSet[id] = true
}
var records []*model.AssetAllocationRecord
for _, device := range devices {
if !successIDSet[device.ID] {
continue
}
record := &model.AssetAllocationRecord{
AllocationNo: allocationNo,
AllocationType: constants.AssetAllocationTypeAllocate,
AssetType: constants.AssetTypeDevice,
AssetID: device.ID,
AssetIdentifier: device.DeviceNo,
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(devices []*model.Device, successDeviceIDs []uint, fromShopID *uint, toShopID *uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord {
successIDSet := make(map[uint]bool)
for _, id := range successDeviceIDs {
successIDSet[id] = true
}
var records []*model.AssetAllocationRecord
for _, device := range devices {
if !successIDSet[device.ID] {
continue
}
record := &model.AssetAllocationRecord{
AllocationNo: allocationNo,
AllocationType: constants.AssetAllocationTypeRecall,
AssetType: constants.AssetTypeDevice,
AssetID: device.ID,
AssetIdentifier: device.DeviceNo,
OperatorID: operatorID,
Remark: remark,
}
if fromShopID == nil {
record.FromOwnerType = constants.OwnerTypePlatform
record.FromOwnerID = nil
} else {
record.FromOwnerType = constants.OwnerTypeShop
record.FromOwnerID = fromShopID
}
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.BatchSetDeviceSeriesBindngRequest, operatorShopID *uint) (*dto.BatchSetDeviceSeriesBindngResponse, error) {
devices, err := s.deviceStore.GetByIDs(ctx, req.DeviceIDs)
if err != nil {
return nil, err
}
if len(devices) == 0 {
return &dto.BatchSetDeviceSeriesBindngResponse{
SuccessCount: 0,
FailCount: len(req.DeviceIDs),
FailedItems: s.buildDeviceNotFoundFailedItems(req.DeviceIDs),
}, nil
}
deviceMap := make(map[uint]*model.Device)
for _, device := range devices {
deviceMap[device.ID] = device
}
// 验证系列存在(仅当 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 successDeviceIDs []uint
var failedItems []dto.DeviceSeriesBindngFailedItem
for _, deviceID := range req.DeviceIDs {
device, exists := deviceMap[deviceID]
if !exists {
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
DeviceID: deviceID,
DeviceNo: "",
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.DeviceSeriesBindngFailedItem{
DeviceID: deviceID,
DeviceNo: device.DeviceNo,
Reason: "您没有权限分配该套餐系列",
})
continue
}
}
// 验证设备权限(基于 device.ShopID
if operatorShopID != nil {
if device.ShopID == nil || *device.ShopID != *operatorShopID {
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
DeviceID: device.ID,
DeviceNo: device.DeviceNo,
Reason: "无权操作此设备",
})
continue
}
}
successDeviceIDs = append(successDeviceIDs, device.ID)
}
if len(successDeviceIDs) > 0 {
var seriesIDPtr *uint
if req.SeriesID > 0 {
seriesIDPtr = &req.SeriesID
}
if err := s.deviceStore.BatchUpdateSeriesID(ctx, successDeviceIDs, seriesIDPtr); err != nil {
return nil, err
}
}
return &dto.BatchSetDeviceSeriesBindngResponse{
SuccessCount: len(successDeviceIDs),
FailCount: len(failedItems),
FailedItems: failedItems,
}, nil
}
func (s *Service) buildDeviceNotFoundFailedItems(deviceIDs []uint) []dto.DeviceSeriesBindngFailedItem {
items := make([]dto.DeviceSeriesBindngFailedItem, len(deviceIDs))
for i, id := range deviceIDs {
items[i] = dto.DeviceSeriesBindngFailedItem{
DeviceID: id,
DeviceNo: "",
Reason: "设备不存在",
}
}
return items
}