feat: 单卡回收接口优化 & 店铺禁用登录拦截
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m0s
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:
@@ -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),
|
||||
|
||||
@@ -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:筛选条件)"`
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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, "店铺默认角色必须是客户角色")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "提现申请不存在",
|
||||
|
||||
Reference in New Issue
Block a user