Browse Source

feat(商家权限): 摊主可管理绑定摊位的商品分类商品规格及订单

MerchantStoreAccessService.resolve() 新增 userType=4 摊主分支:
授权门店即账号绑定摊位,分类/商品/规格/订单接口按摊位放行;
摊主不是商家主账号,requireOwner 的门店主数据操作仍拒绝;
绑定摊位缺失或已删除按商家账号不可用处理。
qmj 1 week ago
parent
commit
b59c29ee50

+ 1 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/constants/MerchantAccountConstants.java

@@ -6,6 +6,7 @@ package com.ruoyi.system.domain.constants;
 public final class MerchantAccountConstants {
 
     public static final String OWNER_USER_TYPE = "1";
+    public static final String STALL_USER_TYPE = "4";
     public static final String SUBACCOUNT_USER_TYPE = "5";
     public static final String STATUS_ENABLED = "0";
     public static final String STATUS_DISABLED = "1";

+ 35 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/MerchantStoreAccessService.java

@@ -43,29 +43,63 @@ public class MerchantStoreAccessService {
         this.posFoodMapper = posFoodMapper;
     }
 
+    /**
+     * 根据登录用户 ID 解析其门店访问上下文。
+     *
+     * 分三种情况:
+     * 1. 商家主账号(userType=1):拥有自己名下所有门店的权限;
+     * 2. 摊主(userType=4):仅拥有账号绑定摊位的权限;
+     * 3. 商家分管账号(userType=5):只有被主账号授权的门店权限,且主账号必须仍然有效。
+     *
+     * @param loginUserId 当前登录用户 ID
+     * @return 访问上下文(是否主账号、所属主账号 ID、可访问的门店 ID 集合)
+     */
     public MerchantAccessContext resolve(Long loginUserId) {
+        // 校验登录用户存在、未删除、未禁用,否则抛出异常
         InfoUser loginUser = requireActiveUser(loginUserId);
+        // 情况一:商家主账号(userType=1)
         if (MerchantAccountConstants.OWNER_USER_TYPE.equals(loginUser.getUserType())) {
+            // 查出该主账号名下的全部门店 ID,主账号身份 = 登录用户本身
             List<Long> owned = posStoreMapper.selectOwnedStoreIdsByUserId(loginUserId);
             return new MerchantAccessContext(loginUserId, loginUserId, true,
                     owned == null ? Set.of() : new LinkedHashSet<>(owned));
         }
+        // 情况二:摊主(userType=4):绑定摊位即全部授权门店,
+        // 可管理本摊位的商品分类/商品/规格及订单;不是商家主账号,
+        // requireOwner 的门店主数据操作仍拒绝。绑定摊位缺失或已删除视为账号不可用。
+        if (MerchantAccountConstants.STALL_USER_TYPE.equals(loginUser.getUserType())) {
+            Long stallStoreId = loginUser.getStoreId();
+            PosStore stall = stallStoreId == null ? null : posStoreMapper.selectPosStoreById(stallStoreId);
+            if (stall == null || !MerchantAccountConstants.NOT_DELETED.equals(stall.getDelFlag())) {
+                throw denied("merchant.account.unavailable");
+            }
+            return new MerchantAccessContext(loginUserId, loginUserId, false, Set.of(stallStoreId));
+        }
+        // 情况三:不是主账号,则必须是分管账号(userType=5),
+        // 且分管账号本身已启用(subaccountStatus=0)、且已绑定主账号(merchantOwnerId 非空),
+        // 任一条件不满足都视为账号不可用
         if (!MerchantAccountConstants.SUBACCOUNT_USER_TYPE.equals(loginUser.getUserType())
                 || !MerchantAccountConstants.STATUS_ENABLED.equals(loginUser.getSubaccountStatus())
                 || loginUser.getMerchantOwnerId() == null) {
             throw denied("merchant.account.unavailable");
         }
 
+        // 查出绑定的主账号,校验其仍是有效的商家主账号(存在、类型正确、启用、未删除)
         InfoUser owner = infoUserMapper.selectInfoUserByUserId(loginUser.getMerchantOwnerId());
         if (!isActiveOwner(owner)) {
             throw denied("merchant.owner.unavailable");
         }
 
+        // 计算分管账号实际可访问的门店集合
         Set<Long> accessible = new LinkedHashSet<>();
+        // 从分管关系表查出被授权的门店 ID 列表
         List<Long> authorized = relationMapper.selectStoreIdsBySubaccountUserId(loginUserId);
         if (authorized != null) {
             for (Long storeId : authorized) {
+                // 逐个加载门店做二次校验
                 PosStore store = storeId == null ? null : posStoreMapper.selectPosStoreById(storeId);
+                // 只保留:门店存在、未删除、且仍归属于该主账号的门店;
+                // (授权后门店被删或被转移给别的商家时,在这里自动失效)
                 if (store != null
                         && MerchantAccountConstants.NOT_DELETED.equals(store.getDelFlag())
                         && Objects.equals(owner.getUserId(), store.getUserId())) {
@@ -73,6 +107,7 @@ public class MerchantStoreAccessService {
                 }
             }
         }
+        // 分管账号:登录用户是分管账号本身,归属主账号是 owner,owner=false
         return new MerchantAccessContext(loginUserId, owner.getUserId(), false, accessible);
     }
 

+ 29 - 0
ruoyi-system/src/test/java/com/ruoyi/system/service/MerchantStoreAccessServiceTest.java

@@ -53,6 +53,35 @@ class MerchantStoreAccessServiceTest {
         messages.close();
     }
 
+    @Test
+    void stallOwnerResolvesOnlyItsBoundStallAndIsNotOwner() {
+        InfoUser stallOwner = user(9L, "4", "0", null, null);
+        stallOwner.setStoreId(205L);
+        when(infoUserMapper.selectInfoUserByUserId(9L)).thenReturn(stallOwner);
+        when(posStoreMapper.selectPosStoreById(205L)).thenReturn(store(205, 862L, "0"));
+
+        MerchantAccessContext context = service.resolve(9L);
+
+        assertEquals(9L, context.ownerUserId());
+        assertEquals(false, context.owner());
+        assertEquals(Set.of(205L), context.storeIds());
+        // 摊主不是商家主账号,requireOwner 的门店主数据操作仍拒绝。
+        assertThrows(ServiceException.class, () -> service.requireOwner(9L));
+    }
+
+    @Test
+    void stallOwnerWithoutUsableBoundStoreIsRejected() {
+        InfoUser unbound = user(9L, "4", "0", null, null);
+        when(infoUserMapper.selectInfoUserByUserId(9L)).thenReturn(unbound);
+        assertThrows(ServiceException.class, () -> service.resolve(9L));
+
+        InfoUser boundToDeleted = user(10L, "4", "0", null, null);
+        boundToDeleted.setStoreId(206L);
+        when(infoUserMapper.selectInfoUserByUserId(10L)).thenReturn(boundToDeleted);
+        when(posStoreMapper.selectPosStoreById(206L)).thenReturn(store(206, 862L, "2"));
+        assertThrows(ServiceException.class, () -> service.resolve(10L));
+    }
+
     @Test
     void ownerReceivesAllCurrentlyOwnedStores() {
         when(infoUserMapper.selectInfoUserByUserId(1L)).thenReturn(user(1L, "1", "0", null, null));