feat: 单卡回收接口优化 & 店铺禁用登录拦截
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m0s

单卡回收优化:
- 移除 from_shop_id 参数,系统自动识别卡所属店铺
- 保持直属下级限制,混合来源分别处理
- 新增 GetDistributedStandaloneByICCIDRange/GetDistributedStandaloneByFilters 方法

店铺禁用拦截:
- 登录时检查关联店铺状态,禁用店铺无法登录
- 新增 CodeShopDisabled 错误码

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 15:54:53 +08:00
parent 25e9749564
commit 037595c22e
7 changed files with 121 additions and 29 deletions

View File

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

View File

@@ -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:筛选条件)"`

View File

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

View File

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

View File

@@ -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, "店铺默认角色必须是客户角色")

View File

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

View File

@@ -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: "提现申请不存在",