diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index d1176b6..6387964 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -97,7 +97,7 @@ func initServices(s *stores, deps *Dependencies) *services { Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis), PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.WechatOfficialAccount, deps.Logger), Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role), - Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, deps.TokenManager, deps.Logger), + Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger), ShopCommission: shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionRecord), CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest), CommissionWithdrawalSetting: commissionWithdrawalSettingSvc.New(deps.DB, s.Account, s.CommissionWithdrawalSetting), diff --git a/internal/model/dto/standalone_card_allocation_dto.go b/internal/model/dto/standalone_card_allocation_dto.go index 91e77ed..74946ed 100644 --- a/internal/model/dto/standalone_card_allocation_dto.go +++ b/internal/model/dto/standalone_card_allocation_dto.go @@ -71,10 +71,8 @@ type AllocationFailedItem struct { // ========== 回收请求/响应 ========== // RecallStandaloneCardsRequest 回收单卡请求 +// 系统自动识别卡所属店铺,只要是操作者的直属下级店铺即可回收 type RecallStandaloneCardsRequest struct { - // FromShopID 来源店铺ID(必填,被回收方,必须是直属下级) - FromShopID uint `json:"from_shop_id" validate:"required,min=1" required:"true" minimum:"1" description:"来源店铺ID(被回收方)"` - // SelectionType 选卡方式(必填) SelectionType string `json:"selection_type" validate:"required,oneof=list range filter" required:"true" enum:"list,range,filter" description:"选卡方式 (list:ICCID列表, range:号段范围, filter:筛选条件)"` diff --git a/internal/service/auth/service.go b/internal/service/auth/service.go index 8ef3f12..fd0138d 100644 --- a/internal/service/auth/service.go +++ b/internal/service/auth/service.go @@ -21,6 +21,7 @@ type Service struct { accountRoleStore *postgres.AccountRoleStore rolePermStore *postgres.RolePermissionStore permissionStore *postgres.PermissionStore + shopStore *postgres.ShopStore tokenManager *auth.TokenManager logger *zap.Logger } @@ -30,6 +31,7 @@ func New( accountRoleStore *postgres.AccountRoleStore, rolePermStore *postgres.RolePermissionStore, permissionStore *postgres.PermissionStore, + shopStore *postgres.ShopStore, tokenManager *auth.TokenManager, logger *zap.Logger, ) *Service { @@ -38,6 +40,7 @@ func New( accountRoleStore: accountRoleStore, rolePermStore: rolePermStore, permissionStore: permissionStore, + shopStore: shopStore, tokenManager: tokenManager, logger: logger, } @@ -65,6 +68,22 @@ func (s *Service) Login(ctx context.Context, req *dto.LoginRequest, clientIP str return nil, errors.New(errors.CodeAccountDisabled, "账号已禁用") } + // 检查店铺状态(代理账号必须关联店铺且店铺必须启用) + if account.ShopID != nil && *account.ShopID > 0 { + shop, err := s.shopStore.GetByID(ctx, *account.ShopID) + if err != nil { + if err == gorm.ErrRecordNotFound { + s.logger.Warn("登录失败:关联店铺不存在", zap.String("username", req.Username), zap.Uint("shop_id", *account.ShopID)) + return nil, errors.New(errors.CodeShopNotFound, "关联店铺不存在") + } + return nil, errors.Wrap(errors.CodeInternalError, err, "查询店铺失败") + } + if shop.Status != constants.StatusEnabled { + s.logger.Warn("登录失败:关联店铺已禁用", zap.String("username", req.Username), zap.Uint("shop_id", *account.ShopID)) + return nil, errors.New(errors.CodeShopDisabled, "店铺已禁用,无法登录") + } + } + device := req.Device if device == "" { device = "web" diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index ee20fd5..4df8eea 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -384,10 +384,7 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone } 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 - } - + // 1. 查询卡列表 cards, err := s.getCardsForRecall(ctx, req) if err != nil { return nil, err @@ -402,7 +399,35 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard }, nil } + // 2. 收集所有卡的店铺 ID,批量查询店铺信息以验证直属下级关系 + shopIDSet := make(map[uint]bool) + for _, card := range cards { + if card.ShopID != nil { + shopIDSet[*card.ShopID] = true + } + } + shopIDs := make([]uint, 0, len(shopIDSet)) + for shopID := range shopIDSet { + shopIDs = append(shopIDs, shopID) + } + + // 3. 批量查询店铺,验证哪些是直属下级 + directSubordinateSet := make(map[uint]bool) + if len(shopIDs) > 0 { + shops, err := s.shopStore.GetByIDs(ctx, shopIDs) + if err != nil { + return nil, err + } + for _, shop := range shops { + if s.isDirectSubordinate(operatorShopID, shop) { + directSubordinateSet[shop.ID] = true + } + } + } + + // 4. 检查绑定设备的卡 var cardIDs []uint + var successCards []*model.IotCard var failedItems []dto.AllocationFailedItem boundCardIDs, err := s.iotCardStore.GetBoundCardIDs(ctx, s.extractCardIDs(cards)) @@ -414,6 +439,7 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard boundCardIDSet[id] = true } + // 5. 逐卡验证:绑定设备、所属店铺是否是直属下级 for _, card := range cards { if boundCardIDSet[card.ID] { failedItems = append(failedItems, dto.AllocationFailedItem{ @@ -423,15 +449,24 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard continue } - if card.ShopID == nil || *card.ShopID != req.FromShopID { + if card.ShopID == nil { failedItems = append(failedItems, dto.AllocationFailedItem{ ICCID: card.ICCID, - Reason: "卡不属于指定的店铺", + Reason: "卡未分配给任何店铺", + }) + continue + } + + if !directSubordinateSet[*card.ShopID] { + failedItems = append(failedItems, dto.AllocationFailedItem{ + ICCID: card.ICCID, + Reason: "卡所属店铺不是您的直属下级", }) continue } cardIDs = append(cardIDs, card.ID) + successCards = append(successCards, card) } if len(cardIDs) == 0 { @@ -443,6 +478,7 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard }, nil } + // 6. 执行回收 isPlatform := operatorShopID == nil var newShopID *uint var newStatus int @@ -454,6 +490,8 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard newStatus = constants.IotCardStatusDistributed } + allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall) + err = s.db.Transaction(func(tx *gorm.DB) error { txIotCardStore := postgres.NewIotCardStore(tx, nil) txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil) @@ -462,8 +500,7 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard return err } - allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall) - records := s.buildRecallRecords(cards, cardIDs, req.FromShopID, operatorShopID, operatorID, allocationNo, req.Remark) + records := s.buildRecallRecords(successCards, operatorShopID, operatorID, allocationNo, req.Remark) return txRecordStore.BatchCreate(ctx, records) }) @@ -482,11 +519,21 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard TotalCount: len(cards), SuccessCount: len(cardIDs), FailCount: len(failedItems), - AllocationNo: s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall), + AllocationNo: allocationNo, FailedItems: failedItems, }, nil } +// isDirectSubordinate 检查店铺是否是操作者的直属下级 +func (s *Service) isDirectSubordinate(operatorShopID *uint, shop *model.Shop) bool { + if operatorShopID == nil { + // 平台用户:直属下级是 parent_id 为空的店铺 + return shop.ParentID == nil + } + // 代理用户:直属下级是 parent_id 等于自己的店铺 + return shop.ParentID != nil && *shop.ParentID == *operatorShopID +} + func (s *Service) validateDirectSubordinate(ctx context.Context, operatorShopID *uint, targetShopID uint) error { if operatorShopID != nil && *operatorShopID == targetShopID { return errors.ErrCannotAllocateToSelf @@ -537,12 +584,12 @@ func (s *Service) getCardsForAllocation(ctx context.Context, req *dto.AllocateSt } 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) + // 查询已分配给店铺的单卡(回收场景) + return s.iotCardStore.GetDistributedStandaloneByICCIDRange(ctx, req.ICCIDStart, req.ICCIDEnd) case dto.SelectionTypeFilter: filters := make(map[string]any) if req.CarrierID != nil { @@ -551,7 +598,8 @@ func (s *Service) getCardsForRecall(ctx context.Context, req *dto.RecallStandalo if req.BatchNo != "" { filters["batch_no"] = req.BatchNo } - return s.iotCardStore.GetStandaloneByFilters(ctx, filters, &fromShopID) + // 查询已分配给店铺的单卡(回收场景) + return s.iotCardStore.GetDistributedStandaloneByFilters(ctx, filters) default: return nil, errors.New(errors.CodeInvalidParam, "无效的选卡方式") } @@ -603,18 +651,9 @@ func (s *Service) buildAllocationRecords(cards []*model.IotCard, successCardIDs 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 - } - +func (s *Service) buildRecallRecords(successCards []*model.IotCard, toShopID *uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord { var records []*model.AssetAllocationRecord - for _, card := range cards { - if !successIDSet[card.ID] { - continue - } - + for _, card := range successCards { record := &model.AssetAllocationRecord{ AllocationNo: allocationNo, AllocationType: constants.AssetAllocationTypeRecall, @@ -622,7 +661,7 @@ func (s *Service) buildRecallRecords(cards []*model.IotCard, successCardIDs []ui AssetID: card.ID, AssetIdentifier: card.ICCID, FromOwnerType: constants.OwnerTypeShop, - FromOwnerID: &fromShopID, + FromOwnerID: card.ShopID, // 从卡的当前所属店铺获取 OperatorID: operatorID, Remark: remark, } diff --git a/internal/service/shop/service.go b/internal/service/shop/service.go index 37a097c..4e51045 100644 --- a/internal/service/shop/service.go +++ b/internal/service/shop/service.go @@ -71,7 +71,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopRequest) (*dto. // 验证默认角色:必须存在、是客户角色且已启用 defaultRole, err := s.roleStore.GetByID(ctx, req.DefaultRoleID) if err != nil { - return nil, errors.New(errors.CodeNotFound, "默认角色不存在") + return nil, errors.New(errors.CodeNotFound, "请选择默认角色") } if defaultRole.RoleType != constants.RoleTypeCustomer { return nil, errors.New(errors.CodeInvalidParam, "店铺默认角色必须是客户角色") diff --git a/internal/store/postgres/iot_card_store.go b/internal/store/postgres/iot_card_store.go index 8087445..7a8fd74 100644 --- a/internal/store/postgres/iot_card_store.go +++ b/internal/store/postgres/iot_card_store.go @@ -673,6 +673,19 @@ func (s *IotCardStore) GetStandaloneByICCIDRange(ctx context.Context, iccidStart return cards, nil } +// GetDistributedStandaloneByICCIDRange 根据号段范围查询已分配给店铺的单卡(用于回收) +func (s *IotCardStore) GetDistributedStandaloneByICCIDRange(ctx context.Context, iccidStart, iccidEnd string) ([]*model.IotCard, error) { + var cards []*model.IotCard + if err := s.db.WithContext(ctx).Model(&model.IotCard{}). + Where("is_standalone = true"). + Where("shop_id IS NOT NULL"). + Where("iccid >= ? AND iccid <= ?", iccidStart, iccidEnd). + Find(&cards).Error; err != nil { + return nil, err + } + return cards, nil +} + func (s *IotCardStore) GetStandaloneByFilters(ctx context.Context, filters map[string]any, shopID *uint) ([]*model.IotCard, error) { query := s.db.WithContext(ctx).Model(&model.IotCard{}). Where("is_standalone = true") @@ -700,6 +713,26 @@ func (s *IotCardStore) GetStandaloneByFilters(ctx context.Context, filters map[s return cards, nil } +// GetDistributedStandaloneByFilters 根据筛选条件查询已分配给店铺的单卡(用于回收) +func (s *IotCardStore) GetDistributedStandaloneByFilters(ctx context.Context, filters map[string]any) ([]*model.IotCard, error) { + query := s.db.WithContext(ctx).Model(&model.IotCard{}). + Where("is_standalone = true"). + Where("shop_id IS NOT NULL") + + if carrierID, ok := filters["carrier_id"].(uint); ok && carrierID > 0 { + query = query.Where("carrier_id = ?", carrierID) + } + if batchNo, ok := filters["batch_no"].(string); ok && batchNo != "" { + query = query.Where("batch_no = ?", batchNo) + } + + 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 diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go index 16e5e08..486157d 100644 --- a/pkg/errors/codes.go +++ b/pkg/errors/codes.go @@ -56,6 +56,7 @@ const ( CodeEnterpriseCodeExists = 1034 // 企业编号已存在 CodeCustomerNotFound = 1035 // 个人客户不存在 CodeCustomerPhoneExists = 1036 // 个人客户手机号已存在 + CodeShopDisabled = 1037 // 店铺已禁用 // 财务相关错误 (1050-1069) CodeInvalidStatus = 1050 // 状态不允许此操作 @@ -179,6 +180,7 @@ var allErrorCodes = []int{ CodeEnterpriseCodeExists, CodeCustomerNotFound, CodeCustomerPhoneExists, + CodeShopDisabled, CodeInvalidCredentials, CodeAccountLocked, CodePasswordExpired, @@ -295,6 +297,7 @@ var errorMessages = map[int]string{ CodeEnterpriseCodeExists: "企业编号已存在", CodeCustomerNotFound: "个人客户不存在", CodeCustomerPhoneExists: "个人客户手机号已存在", + CodeShopDisabled: "店铺已禁用", CodeInvalidStatus: "状态不允许此操作", CodeInsufficientBalance: "余额不足", CodeWithdrawalNotFound: "提现申请不存在",