Ver Fonte

feat(pay): support stall-specific payment settings

qmj há 2 semanas atrás
pai
commit
eff4cb8a47
26 ficheiros alterados com 865 adições e 92 exclusões
  1. 10 7
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java
  2. 67 32
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/BankCardController.java
  3. 56 17
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/MerchantPayMethodController.java
  4. 18 8
      ruoyi-admin/src/main/java/com/ruoyi/app/stall/StallController.java
  5. 79 0
      ruoyi-admin/src/test/java/com/ruoyi/app/mendian/StallBankInfoTest.java
  6. 61 1
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/MerchantPayMethodControllerTest.java
  7. 18 1
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/PaymentMethodGateSettingsReadyTest.java
  8. 145 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/StallBankCardControllerTest.java
  9. 70 0
      ruoyi-admin/src/test/java/com/ruoyi/app/stall/StallControllerTest.java
  10. 4 2
      ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoBankCard.java
  11. 5 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStore.java
  12. 4 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreMapper.java
  13. 40 15
      ruoyi-system/src/main/java/com/ruoyi/system/service/PaymentMethodGateService.java
  14. 43 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/StallPaymentAccessService.java
  15. 1 0
      ruoyi-system/src/main/resources/mapper/chanting/PosStoreMapper.xml
  16. 34 1
      ruoyi-system/src/test/java/com/ruoyi/system/service/PaymentMethodGateServiceTest.java
  17. 23 0
      ruoyi-system/src/test/java/com/ruoyi/system/service/PosStorePaymentBindingTest.java
  18. 104 0
      ruoyi-system/src/test/java/com/ruoyi/system/service/StallPaymentAccessServiceTest.java
  19. 2 0
      specs/001-food-stall/spec.md
  20. 10 2
      specs/031-pay-method-config/contracts/api.md
  21. 12 0
      specs/031-pay-method-config/data-model.md
  22. 2 0
      specs/031-pay-method-config/plan.md
  23. 8 0
      specs/031-pay-method-config/quickstart.md
  24. 28 6
      specs/031-pay-method-config/spec.md
  25. 7 0
      specs/031-pay-method-config/tasks.md
  26. 14 0
      updatesql/sql.md

+ 10 - 7
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java

@@ -247,10 +247,7 @@ public class PosStoreController extends BaseController {
 
     }
 
-    /**
-     * 商家收款账户(線下轉賬收银台展示用):门店 到 所属商家主账号 到 审核资料银行三字段
-     * 商家级数据,连锁门店共享同一收款账户;任一字段为空返回 data=null,客户端据此隐藏線下支付选项
-     */
+    /** 商家线下转账收款账户:普通店按主账号、夜市摊位按 store_id 读取启用卡。 */
     @Anonymous
     @GetMapping("/bankInfo")
     public AjaxResult bankInfo(@RequestParam Integer id) {
@@ -268,10 +265,16 @@ public class PosStoreController extends BaseController {
         }
         // 031:数据源改收款银行卡启用卡(027 存量三字段已迁移);判据从"三字段齐全"改为"存在启用卡"。
         // 防御多行启用脏数据:取第一张而非 selectOne(多行会抛底层异常)。
+        com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.ruoyi.system.domain.InfoBankCard> cardQuery =
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
+        if (Integer.valueOf(1).equals(store.getIsStall())) {
+            // 摊位的 user_id 是夜市主;只读取本摊位启用卡,避免不同摊位串用收款账户。
+            cardQuery.eq(com.ruoyi.system.domain.InfoBankCard::getStoreId, store.getId().longValue());
+        } else {
+            cardQuery.eq(com.ruoyi.system.domain.InfoBankCard::getUserId, store.getUserId());
+        }
         java.util.List<com.ruoyi.system.domain.InfoBankCard> cards = infoBankCardMapper.selectList(
-                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.ruoyi.system.domain.InfoBankCard>()
-                        .eq(com.ruoyi.system.domain.InfoBankCard::getUserId, store.getUserId())
-                        .eq(com.ruoyi.system.domain.InfoBankCard::getIsActive, 1)
+                cardQuery.eq(com.ruoyi.system.domain.InfoBankCard::getIsActive, 1)
                         .orderByDesc(com.ruoyi.system.domain.InfoBankCard::getId)
                         .last("LIMIT 1"));
         if (cards.isEmpty()) {

+ 67 - 32
ruoyi-admin/src/main/java/com/ruoyi/app/pay/BankCardController.java

@@ -15,7 +15,9 @@ import com.ruoyi.system.domain.InfoBankCard;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.mapper.InfoBankCardMapper;
 import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.service.StallPaymentAccessService;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.transaction.annotation.Transactional;
@@ -31,10 +33,11 @@ import org.springframework.web.bind.annotation.RestController;
 
 import java.util.Date;
 import java.util.List;
+import java.util.Objects;
 
 /**
  * 收款银行卡管理接口(031,商家/骑手通用;foodie-store 支付设置页与骑手 App 共用)。
- * 卡归属:骑手=本人;商家(含子账号)=主账号(连锁共享,与支付方式选择同语义)。
+ * 卡归属:骑手=本人;普通商家(含子账号)=主账号;夜市摊位=store_id。
  * 同一归属同时最多一张启用卡;付款方仅能看到启用卡(bankInfo 已改读启用卡)。
  */
 @RestController
@@ -42,29 +45,34 @@ import java.util.List;
 @Anonymous
 public class BankCardController extends BaseController {
 
-    /** 单人卡数上限,防止无限囤卡。 */
+    /** 每个归属(主账号、骑手或摊位)的卡数上限,防止无限囤卡。 */
     private static final int MAX_CARDS = 10;
 
     private final InfoBankCardMapper bankCardMapper;
     private final InfoUserMapper userMapper;
     private final MerchantStoreAccessService merchantStoreAccessService;
+    private final StallPaymentAccessService stallPaymentAccessService;
+    private final PosStoreMapper storeMapper;
 
     public BankCardController(InfoBankCardMapper bankCardMapper,
                               InfoUserMapper userMapper,
-                              MerchantStoreAccessService merchantStoreAccessService) {
+                              MerchantStoreAccessService merchantStoreAccessService,
+                              StallPaymentAccessService stallPaymentAccessService,
+                              PosStoreMapper storeMapper) {
         this.bankCardMapper = bankCardMapper;
         this.userMapper = userMapper;
         this.merchantStoreAccessService = merchantStoreAccessService;
+        this.stallPaymentAccessService = stallPaymentAccessService;
+        this.storeMapper = storeMapper;
     }
 
-    /** 本人(商家为主账号)卡列表,含启用标记。 */
+    /** 本归属(普通商家主账号、骑手本人或摊位)卡列表,含启用标记。 */
     @Auth
     @GetMapping
     public AjaxResult list(@RequestHeader String token) {
-        Long ownerId = resolveOwner(token);
+        CardOwner owner = resolveOwner(token);
         List<InfoBankCard> cards = bankCardMapper.selectList(
-                new LambdaQueryWrapper<InfoBankCard>()
-                        .eq(InfoBankCard::getUserId, ownerId)
+                ownerQuery(owner)
                         .orderByDesc(InfoBankCard::getIsActive)
                         .orderByDesc(InfoBankCard::getId));
         return success(cards);
@@ -75,14 +83,16 @@ public class BankCardController extends BaseController {
     @PostMapping
     @Transactional(rollbackFor = Exception.class)
     public AjaxResult add(@RequestHeader String token, @RequestBody BankCardSaveDto dto) {
-        Long ownerId = resolveOwner(token);
-        InfoBankCard card = validated(dto, ownerId);
+        CardOwner owner = resolveOwner(token);
+        InfoBankCard card = validated(dto);
+        lockStall(owner);
         long count = bankCardMapper.selectCount(
-                new LambdaQueryWrapper<InfoBankCard>().eq(InfoBankCard::getUserId, ownerId));
+                ownerQuery(owner));
         if (count >= MAX_CARDS) {
             throw new ServiceException(MessageUtils.message("pay.bankcard.limit"));
         }
-        card.setUserId(ownerId);
+        card.setUserId(owner.userId());
+        card.setStoreId(owner.storeId());
         // 首张卡自动启用,避免迁移遗漏或新收款方忘记启用导致线下转账长期不就绪
         card.setIsActive(count == 0 ? 1 : 0);
         Date now = new Date();
@@ -96,12 +106,12 @@ public class BankCardController extends BaseController {
     @Auth
     @PutMapping
     public AjaxResult update(@RequestHeader String token, @RequestBody BankCardSaveDto dto) {
-        Long ownerId = resolveOwner(token);
+        CardOwner owner = resolveOwner(token);
         if (dto == null || dto.getId() == null) {
             throw new ServiceException(MessageUtils.message("pay.bankcard.invalid"));
         }
-        InfoBankCard existing = requireOwnCard(dto.getId(), ownerId);
-        InfoBankCard validated = validated(dto, ownerId);
+        InfoBankCard existing = requireOwnCard(dto.getId(), owner);
+        InfoBankCard validated = validated(dto);
         existing.setBankName(validated.getBankName());
         existing.setAccountNo(validated.getAccountNo());
         existing.setAccountName(validated.getAccountName());
@@ -110,12 +120,12 @@ public class BankCardController extends BaseController {
         return success();
     }
 
-    /** 删除本人卡;删的是启用卡则该归属回到"无启用卡"(线下转账不就绪)。 */
+    /** 删除本归属卡;删的是启用卡则该归属回到"无启用卡"(线下转账不就绪)。 */
     @Auth
     @DeleteMapping("/{id}")
     public AjaxResult delete(@RequestHeader String token, @PathVariable Long id) {
-        Long ownerId = resolveOwner(token);
-        requireOwnCard(id, ownerId);
+        CardOwner owner = resolveOwner(token);
+        requireOwnCard(id, owner);
         bankCardMapper.deleteById(id);
         return success();
     }
@@ -128,14 +138,14 @@ public class BankCardController extends BaseController {
     @PutMapping("/activate/{id}")
     @Transactional(rollbackFor = Exception.class)
     public AjaxResult activate(@RequestHeader String token, @PathVariable Long id) {
-        Long ownerId = resolveOwner(token);
-        requireOwnCard(id, ownerId);
+        CardOwner owner = resolveOwner(token);
+        lockStall(owner);
+        requireOwnCard(id, owner);
         Date now = new Date();
-        bankCardMapper.update(null, new LambdaUpdateWrapper<InfoBankCard>()
-                .eq(InfoBankCard::getUserId, ownerId)
+        bankCardMapper.update(null, ownerUpdate(owner)
                 .set(InfoBankCard::getIsActive, 0)
                 .set(InfoBankCard::getUpdateTime, now));
-        bankCardMapper.update(null, new LambdaUpdateWrapper<InfoBankCard>()
+        bankCardMapper.update(null, ownerUpdate(owner)
                 .eq(InfoBankCard::getId, id)
                 .set(InfoBankCard::getIsActive, 1)
                 .set(InfoBankCard::getUpdateTime, now));
@@ -143,7 +153,7 @@ public class BankCardController extends BaseController {
     }
 
     /** 字段校验:必填、长度、银行名必须属于字典 taiwan_bank_list(与商家审核资料同源)。 */
-    private InfoBankCard validated(BankCardSaveDto dto, Long ownerId) {
+    private InfoBankCard validated(BankCardSaveDto dto) {
         if (dto == null || StringUtils.isBlank(dto.getBankName())
                 || StringUtils.isBlank(dto.getAccountNo()) || StringUtils.isBlank(dto.getAccountName())
                 || dto.getBankName().trim().length() > 64
@@ -171,28 +181,53 @@ public class BankCardController extends BaseController {
         return card;
     }
 
-    /** 只能操作归属自己的卡,统一按无权限处理避免枚举他人卡 ID。 */
-    private InfoBankCard requireOwnCard(Long id, Long ownerId) {
+    /** 只能操作同一用户或同一摊位的卡,两类归属都比较,避免枚举其他归属卡 ID。 */
+    private InfoBankCard requireOwnCard(Long id, CardOwner owner) {
         InfoBankCard card = id == null ? null : bankCardMapper.selectById(id);
-        if (card == null || !ownerId.equals(card.getUserId())) {
+        if (card == null || !Objects.equals(owner.userId(), card.getUserId())
+                || !Objects.equals(owner.storeId(), card.getStoreId())) {
             throw new ServiceException(MessageUtils.message("no.user.stop"));
         }
         return card;
     }
 
-    /** token → 卡归属:骑手(2)=本人;商家系(1/3/4/5)=主账号;普通用户不可维护收款卡。 */
-    private Long resolveOwner(String token) {
+    /** 用户卡和摊位卡互斥;全部读写都由服务端解析归属,不能靠请求参数指定。 */
+    private record CardOwner(Long userId, Long storeId) { }
+
+    private LambdaQueryWrapper<InfoBankCard> ownerQuery(CardOwner owner) {
+        LambdaQueryWrapper<InfoBankCard> query = new LambdaQueryWrapper<>();
+        return owner.storeId() != null ? query.eq(InfoBankCard::getStoreId, owner.storeId())
+                : query.eq(InfoBankCard::getUserId, owner.userId());
+    }
+
+    private LambdaUpdateWrapper<InfoBankCard> ownerUpdate(CardOwner owner) {
+        LambdaUpdateWrapper<InfoBankCard> update = new LambdaUpdateWrapper<>();
+        return owner.storeId() != null ? update.eq(InfoBankCard::getStoreId, owner.storeId())
+                : update.eq(InfoBankCard::getUserId, owner.userId());
+    }
+
+    /** 多摊位主可能同时新增首卡或切换启用卡;锁摊位行直至事务结束,保证同一摊位串行处理。 */
+    private void lockStall(CardOwner owner) {
+        if (owner.storeId() != null && storeMapper.lockStallForPayment(owner.storeId()) == null) {
+            throw new ServiceException(MessageUtils.message("merchant.store.access.denied"));
+        }
+    }
+
+    /** token → 卡归属:骑手本人、普通商家主账号或有效摊位;夜市主不可维护摊位卡。 */
+    private CardOwner resolveOwner(String token) {
         Long userId = Long.valueOf(new JwtUtil().getusid(token));
         InfoUser user = userMapper.selectById(userId);
         if (user == null) {
             throw new ServiceException(MessageUtils.message("no.user.stop"));
         }
         if ("2".equals(user.getUserType())) {
-            return userId;
+            return new CardOwner(userId, null);
+        }
+        if ("4".equals(user.getUserType())) {
+            return new CardOwner(null, stallPaymentAccessService.resolveStoreId(userId));
         }
-        if ("1".equals(user.getUserType()) || "3".equals(user.getUserType())
-                || "4".equals(user.getUserType()) || "5".equals(user.getUserType())) {
-            return merchantStoreAccessService.resolve(userId).ownerUserId();
+        if ("1".equals(user.getUserType()) || "5".equals(user.getUserType())) {
+            return new CardOwner(merchantStoreAccessService.resolve(userId).ownerUserId(), null);
         }
         throw new ServiceException(MessageUtils.message("no.user.stop"));
     }

+ 56 - 17
ruoyi-admin/src/main/java/com/ruoyi/app/pay/MerchantPayMethodController.java

@@ -14,6 +14,7 @@ import com.ruoyi.system.mapper.InfoUserMapper;
 import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.system.service.MerchantStoreAccessService;
 import com.ruoyi.system.service.PaymentMethodGateService;
+import com.ruoyi.system.service.StallPaymentAccessService;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.web.bind.annotation.GetMapping;
@@ -30,8 +31,8 @@ import java.util.Map;
 
 /**
  * 商家端支付方式选择接口(031,foodie-store 支付设置页)。
- * 选择在商家主账号级生效(连锁共享);未设置(NULL)=全部启用,存量商家行为零变化。
- * 子账号编辑写其主账号(022 体系,与银行信息语义一致)。
+ * 普通商家选择在主账号级生效(连锁共享);夜市摊位选择归属 store_id,
+ * 关联该摊位的有效摊位主共同维护。未设置(NULL)=全部启用。
  */
 @RestController
 @RequestMapping("/system/merchantPayMethods")
@@ -42,15 +43,18 @@ public class MerchantPayMethodController extends BaseController {
     private final InfoUserMapper userMapper;
     private final MerchantStoreAccessService merchantStoreAccessService;
     private final PosStoreMapper storeMapper;
+    private final StallPaymentAccessService stallPaymentAccessService;
 
     public MerchantPayMethodController(PaymentMethodGateService gateService,
                                        InfoUserMapper userMapper,
                                        MerchantStoreAccessService merchantStoreAccessService,
-                                       PosStoreMapper storeMapper) {
+                                       PosStoreMapper storeMapper,
+                                       StallPaymentAccessService stallPaymentAccessService) {
         this.gateService = gateService;
         this.userMapper = userMapper;
         this.merchantStoreAccessService = merchantStoreAccessService;
         this.storeMapper = storeMapper;
+        this.stallPaymentAccessService = stallPaymentAccessService;
     }
 
     /**
@@ -61,17 +65,30 @@ public class MerchantPayMethodController extends BaseController {
     @Auth
     @GetMapping
     public AjaxResult get(@RequestHeader String token) {
-        Long ownerUserId = resolveOwner(token);
-        InfoUser owner = userMapper.selectById(ownerUserId);
+        Long loginUserId = loginUserId(token);
+        boolean stallOwner = isStallOwner(loginUserId);
+        Long ownerUserId = stallOwner ? null : merchantStoreAccessService.resolve(loginUserId).ownerUserId();
         List<Long> storeIds = new ArrayList<>();
-        for (PosStore store : storeMapper.selectList(new QueryWrapper<PosStore>()
-                .eq("user_id", ownerUserId).select("id"))) {
-            storeIds.add(store.getId().longValue());
+        String csv;
+        if (stallOwner) {
+            Long storeId = stallPaymentAccessService.resolveStoreId(loginUserId);
+            PosStore stall = storeMapper.selectById(storeId);
+            if (stall == null) {
+                throw new ServiceException(MessageUtils.message("merchant.store.access.denied"));
+            }
+            storeIds.add(storeId);
+            csv = stall.getPayMethods();
+        } else {
+            InfoUser owner = userMapper.selectById(ownerUserId);
+            for (PosStore store : storeMapper.selectList(new QueryWrapper<PosStore>()
+                    .eq("user_id", ownerUserId).select("id"))) {
+                storeIds.add(store.getId().longValue());
+            }
+            csv = owner == null ? null : owner.getPayMethods();
         }
         List<PaymentMethodGateService.PayMethodOption> available =
                 gateService.listAvailableForMerchantSettings(ownerUserId, storeIds);
         List<String> selected = new ArrayList<>();
-        String csv = owner == null ? null : owner.getPayMethods();
         if (csv != null && !csv.trim().isEmpty()) {
             for (String part : csv.split(",")) {
                 if (!part.trim().isEmpty()) selected.add(part.trim());
@@ -90,7 +107,9 @@ public class MerchantPayMethodController extends BaseController {
     @Auth
     @PutMapping
     public AjaxResult save(@RequestHeader String token, @RequestBody MerchantPayMethodSaveDto dto) {
-        Long ownerUserId = resolveOwner(token);
+        Long loginUserId = loginUserId(token);
+        boolean stallOwner = isStallOwner(loginUserId);
+        Long ownerUserId = stallOwner ? null : merchantStoreAccessService.resolve(loginUserId).ownerUserId();
         List<String> groups = PaymentMethodGateService.groupsOf(PaymentMethodGateService.GateScope.MERCHANT);
         String csv = null;
         if (dto != null && dto.getMethodCodes() != null && !dto.getMethodCodes().isEmpty()) {
@@ -101,15 +120,35 @@ public class MerchantPayMethodController extends BaseController {
             }
             csv = String.join(",", dto.getMethodCodes());
         }
-        userMapper.update(null, new UpdateWrapper<InfoUser>()
-                .eq("user_id", ownerUserId)
-                .set("pay_methods", csv));
+        if (stallOwner) {
+            Long storeId = stallPaymentAccessService.resolveStoreId(loginUserId);
+            // NULL=全部;用显式 SET 保证清空选择时也能持久化 NULL。
+            int changed = storeMapper.update(null, new UpdateWrapper<PosStore>()
+                    .eq("id", storeId).eq("is_stall", 1).eq("del_flag", "0")
+                    .set("pay_methods", csv));
+            if (changed == 0) {
+                // 驱动可按“实际改变行数”报告 0;同值重复保存应幂等,仅摊位消失时拒绝。
+                PosStore current = storeMapper.selectById(storeId);
+                if (current == null || !Integer.valueOf(1).equals(current.getIsStall())
+                        || !"0".equals(current.getDelFlag())) {
+                    throw new ServiceException(MessageUtils.message("merchant.store.access.denied"));
+                }
+            }
+        } else {
+            userMapper.update(null, new UpdateWrapper<InfoUser>()
+                    .eq("user_id", ownerUserId)
+                    .set("pay_methods", csv));
+        }
         return success();
     }
 
-    /** token(主账号或子账号) → 主账号用户 ID。 */
-    private Long resolveOwner(String token) {
-        Long loginUserId = Long.valueOf(new JwtUtil().getusid(token));
-        return merchantStoreAccessService.resolve(loginUserId).ownerUserId();
+    private Long loginUserId(String token) {
+        return Long.valueOf(new JwtUtil().getusid(token));
+    }
+
+    /** 仅用身份决定分支;摊位的有效性和归属由专用服务再次校验。 */
+    private boolean isStallOwner(Long userId) {
+        InfoUser user = userMapper.selectById(userId);
+        return user != null && "4".equals(user.getUserType());
     }
 }

+ 18 - 8
ruoyi-admin/src/main/java/com/ruoyi/app/stall/StallController.java

@@ -6,6 +6,7 @@ import com.ruoyi.app.user.BusinessPhoneService;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.utils.SecurityUtils;
 import com.ruoyi.system.domain.InfoUser;
@@ -92,19 +93,28 @@ public class StallController extends BaseController {
         return success(MessageUtils.message("no.success"), posStore);
     }
 
-    /**
-     * 编辑摊位
-     */
+    /** 夜市主只能编辑自己创建的摊位;支付归属字段取数据库原值,不能随表单改变。 */
     @Anonymous
     @Auth
     @PostMapping("/editStall")
     @Transactional
     public AjaxResult editStall(@RequestHeader String token, @RequestBody PosStore posStore) {
-        JwtUtil jwtUtil = new JwtUtil();
-//        String userId = jwtUtil.getusid(token);
-//        posStore.setUserId(Long.valueOf(userId));
-//        posStore.setIsStall(1);
-//        posStore.setNightMarketId(Long.valueOf(userId));
+        Long loginUserId = Long.valueOf(new JwtUtil().getusid(token));
+        InfoUser loginUser = infoUserService.selectInfoUserByUserId(loginUserId);
+        PosStore existing = posStore == null || posStore.getId() == null ? null
+                : posStoreService.selectPosStoreById(posStore.getId().longValue());
+        if (loginUser == null || !"3".equals(loginUser.getUserType())
+                || !"0".equals(loginUser.getStatus()) || !"0".equals(loginUser.getDelFlag())
+                || existing == null || !Integer.valueOf(1).equals(existing.getIsStall())
+                || !loginUserId.equals(existing.getUserId())) {
+            throw new ServiceException(MessageUtils.message("merchant.store.access.denied"));
+        }
+        // saveOrUpdate 会写入请求中的非空字段;用原值覆盖支付路由和归属字段。
+        posStore.setUserId(existing.getUserId());
+        posStore.setIsStall(existing.getIsStall());
+        posStore.setNightMarketId(existing.getNightMarketId());
+        posStore.setDelFlag(existing.getDelFlag());
+        posStore.setPayMethods(null);
         posStoreService.saveOrUpdate(posStore);
         return success(MessageUtils.message("no.success"), posStore);
     }

+ 79 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/mendian/StallBankInfoTest.java

@@ -0,0 +1,79 @@
+package com.ruoyi.app.mendian;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.system.domain.InfoBankCard;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.mapper.InfoBankCardMapper;
+import com.ruoyi.system.service.IPosStoreService;
+import com.ruoyi.system.service.PaymentMethodGateService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.AfterAll;
+import org.mockito.ArgumentCaptor;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class StallBankInfoTest {
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    @BeforeAll
+    static void messages() {
+        com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
+                new org.apache.ibatis.builder.MapperBuilderAssistant(
+                        new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""), InfoBankCard.class);
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(com.ruoyi.common.utils.spring.SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beans = new DefaultListableBeanFactory();
+        StaticMessageSource messages = new StaticMessageSource();
+        messages.addMessage("no.action.success", Locale.getDefault(), "成功");
+        beans.registerSingleton("messageSource", messages);
+        new com.ruoyi.common.utils.spring.SpringUtils().postProcessBeanFactory(beans);
+    }
+
+    @AfterAll
+    static void restore() {
+        new com.ruoyi.common.utils.spring.SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @Test
+    void checkoutReadsStallCardNotNightMarketOwnersCard() {
+        PosStoreController controller = new PosStoreController();
+        IPosStoreService stores = mock(IPosStoreService.class);
+        InfoBankCardMapper cards = mock(InfoBankCardMapper.class);
+        ReflectionTestUtils.setField(controller, "posStoreService", stores);
+        ReflectionTestUtils.setField(controller, "paymentMethodGateService", mock(PaymentMethodGateService.class));
+        ReflectionTestUtils.setField(controller, "infoBankCardMapper", cards);
+        PosStore stall = new PosStore();
+        stall.setId(11);
+        stall.setUserId(7L); // 夜市主,不是该卡的归属
+        stall.setIsStall(1);
+        when(stores.getById(11)).thenReturn(stall);
+        InfoBankCard card = new InfoBankCard();
+        card.setStoreId(11L);
+        card.setBankName("银行");
+        card.setAccountName("摊位主");
+        card.setAccountNo("123");
+        when(cards.selectList(any())).thenReturn(List.of(card));
+
+        AjaxResult result = controller.bankInfo(11);
+
+        assertEquals("摊位主", ((cn.hutool.json.JSONObject) result.get(AjaxResult.DATA_TAG)).getStr("accountName"));
+        @SuppressWarnings("rawtypes")
+        ArgumentCaptor<LambdaQueryWrapper> query = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(cards).selectList(query.capture());
+        assertTrue(query.getValue().getSqlSegment().contains("store_id"));
+    }
+}

+ 61 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/pay/MerchantPayMethodControllerTest.java

@@ -10,6 +10,7 @@ import com.ruoyi.system.mapper.InfoUserMapper;
 import com.ruoyi.system.service.MerchantAccessContext;
 import com.ruoyi.system.service.MerchantStoreAccessService;
 import com.ruoyi.system.service.PaymentMethodGateService;
+import com.ruoyi.system.service.StallPaymentAccessService;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
@@ -42,6 +43,7 @@ class MerchantPayMethodControllerTest {
     private InfoUserMapper userMapper;
     private MerchantStoreAccessService merchantStoreAccessService;
     private com.ruoyi.system.mapper.PosStoreMapper storeMapper;
+    private StallPaymentAccessService stallPaymentAccessService;
     private com.ruoyi.system.utils.JwtUtil jwtUtil;
     private static ConfigurableListableBeanFactory originalBeanFactory;
 
@@ -52,6 +54,7 @@ class MerchantPayMethodControllerTest {
         DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
         StaticMessageSource messageSource = new StaticMessageSource();
         messageSource.addMessage("pay.method.config.invalid", Locale.getDefault(), "支付方式设置参数不合法");
+        messageSource.addMessage("merchant.store.access.denied", Locale.getDefault(), "无摊位权限");
         messageSource.addMessage("no.action.success", Locale.getDefault(), "操作成功");
         beanFactory.registerSingleton("messageSource", messageSource);
         new SpringUtils().postProcessBeanFactory(beanFactory);
@@ -68,7 +71,9 @@ class MerchantPayMethodControllerTest {
         userMapper = mock(InfoUserMapper.class);
         merchantStoreAccessService = mock(MerchantStoreAccessService.class);
         storeMapper = mock(com.ruoyi.system.mapper.PosStoreMapper.class);
-        controller = new MerchantPayMethodController(gateService, userMapper, merchantStoreAccessService, storeMapper);
+        stallPaymentAccessService = mock(StallPaymentAccessService.class);
+        controller = new MerchantPayMethodController(gateService, userMapper, merchantStoreAccessService,
+                storeMapper, stallPaymentAccessService);
         when(gateService.listAvailableForMerchantSettings(any(), any())).thenReturn(List.of());
         // 子账号 9 定位主账号 7
         MerchantAccessContext context = mock(MerchantAccessContext.class);
@@ -145,4 +150,59 @@ class MerchantPayMethodControllerTest {
         assertEquals("支付方式设置参数不合法", exception.getMessage());
         verify(userMapper, never()).update(any(), any());
     }
+
+    @Test
+    void stallOwnerReadsStoreSelectionWithoutReadingNightMarketAccount() {
+        InfoUser login = new InfoUser();
+        login.setUserType("4");
+        when(userMapper.selectById(101L)).thenReturn(login);
+        when(stallPaymentAccessService.resolveStoreId(101L)).thenReturn(11L);
+        com.ruoyi.system.domain.PosStore stall = new com.ruoyi.system.domain.PosStore();
+        stall.setId(11);
+        stall.setPayMethods("COD");
+        when(storeMapper.selectById(11L)).thenReturn(stall);
+
+        AjaxResult result = controller.get(tokenFor(101L));
+
+        @SuppressWarnings("unchecked")
+        Map<String, Object> data = (Map<String, Object>) result.get(AjaxResult.DATA_TAG);
+        assertEquals(List.of("COD"), data.get("selected"));
+        verify(gateService).listAvailableForMerchantSettings(null, List.of(11L));
+        verify(userMapper, never()).selectById(7L);
+    }
+
+    @Test
+    void stallOwnerSavesSelectionToStallNotNightMarketAccount() {
+        InfoUser login = new InfoUser();
+        login.setUserType("4");
+        when(userMapper.selectById(101L)).thenReturn(login);
+        when(stallPaymentAccessService.resolveStoreId(101L)).thenReturn(11L);
+        when(storeMapper.update(any(), any(com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper.class)))
+                .thenReturn(1);
+        MerchantPayMethodSaveDto dto = new MerchantPayMethodSaveDto();
+        dto.setMethodCodes(List.of("COD"));
+
+        controller.save(tokenFor(101L), dto);
+
+        verify(storeMapper).update(any(), any(com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper.class));
+        verify(userMapper, never()).update(any(), any());
+    }
+
+    @Test
+    void savingUnchangedStallSelectionIsIdempotent() {
+        InfoUser login = new InfoUser();
+        login.setUserType("4");
+        when(userMapper.selectById(101L)).thenReturn(login);
+        when(stallPaymentAccessService.resolveStoreId(101L)).thenReturn(11L);
+        com.ruoyi.system.domain.PosStore stall = new com.ruoyi.system.domain.PosStore();
+        stall.setId(11);
+        stall.setIsStall(1);
+        stall.setDelFlag("0");
+        when(storeMapper.selectById(11L)).thenReturn(stall);
+        MerchantPayMethodSaveDto dto = new MerchantPayMethodSaveDto();
+        dto.setMethodCodes(List.of("COD"));
+
+        // MySQL affected-rows 配置不同,重复保存可能报告 0 行变化,仍应成功。
+        assertEquals(HttpStatus.SUCCESS, controller.save(tokenFor(101L), dto).get(AjaxResult.CODE_TAG));
+    }
 }

+ 18 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/pay/PaymentMethodGateSettingsReadyTest.java

@@ -6,6 +6,7 @@ import com.ruoyi.system.mapper.InfoUserMapper;
 import com.ruoyi.system.mapper.PaymentMethodConfigMapper;
 import com.ruoyi.system.mapper.PosStoreLinePayMapper;
 import com.ruoyi.system.mapper.PosStoreOmgMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.system.service.PaymentMethodGateService;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -31,6 +32,7 @@ class PaymentMethodGateSettingsReadyTest {
     private InfoBankCardMapper bankCardMapper;
     private PosStoreOmgMapper omgMapper;
     private PosStoreLinePayMapper linePayMapper;
+    private PosStoreMapper storeMapper;
 
     @BeforeEach
     void setUp() {
@@ -39,8 +41,9 @@ class PaymentMethodGateSettingsReadyTest {
         bankCardMapper = mock(InfoBankCardMapper.class);
         omgMapper = mock(PosStoreOmgMapper.class);
         linePayMapper = mock(PosStoreLinePayMapper.class);
+        storeMapper = mock(PosStoreMapper.class);
         gateService = new PaymentMethodGateService(
-                configMapper, userMapper, bankCardMapper, omgMapper, linePayMapper);
+                configMapper, userMapper, bankCardMapper, omgMapper, linePayMapper, storeMapper);
         // 平台缺行=开;selectCount 缺省返回 null 会拆箱 NPE,统一补 0
         when(configMapper.selectList(any())).thenReturn(List.of());
         when(bankCardMapper.selectCount(any())).thenReturn(0L);
@@ -127,4 +130,18 @@ class PaymentMethodGateSettingsReadyTest {
         assertEquals("2", option(options, "2").getPayType());
         assertEquals("5", option(options, "5").getPayType());
     }
+
+    @Test
+    void stallOfflineReadinessChecksStoreCard() {
+        when(bankCardMapper.selectCount(any())).thenAnswer(invocation -> {
+            String sql = invocation.<com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<com.ruoyi.system.domain.InfoBankCard>>getArgument(0)
+                    .getSqlSegment();
+            return sql.contains("store_id") ? 1L : 0L;
+        });
+
+        List<PaymentMethodGateService.PayMethodOption> options =
+                gateService.listAvailableForMerchantSettings(null, List.of(11L));
+
+        assertTrue(option(options, "6").isReady());
+    }
 }

+ 145 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/StallBankCardControllerTest.java

@@ -0,0 +1,145 @@
+package com.ruoyi.app.pay;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.app.pay.dto.BankCardSaveDto;
+import com.ruoyi.system.domain.InfoBankCard;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.mapper.InfoBankCardMapper;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.service.StallPaymentAccessService;
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import org.mockito.ArgumentCaptor;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class StallBankCardControllerTest {
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+    private InfoBankCardMapper cardMapper;
+    private InfoUserMapper userMapper;
+    private StallPaymentAccessService stallAccess;
+    private PosStoreMapper storeMapper;
+    private BankCardController controller;
+
+    @BeforeAll
+    static void messages() {
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), InfoBankCard.class);
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beans = new DefaultListableBeanFactory();
+        StaticMessageSource messages = new StaticMessageSource();
+        messages.addMessage("no.user.stop", Locale.getDefault(), "无权限");
+        messages.addMessage("no.action.success", Locale.getDefault(), "成功");
+        beans.registerSingleton("messageSource", messages);
+        new SpringUtils().postProcessBeanFactory(beans);
+    }
+
+    @AfterAll
+    static void restore() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @BeforeEach
+    void setUp() {
+        cardMapper = mock(InfoBankCardMapper.class);
+        userMapper = mock(InfoUserMapper.class);
+        stallAccess = mock(StallPaymentAccessService.class);
+        storeMapper = mock(PosStoreMapper.class);
+        controller = new BankCardController(cardMapper, userMapper,
+                mock(MerchantStoreAccessService.class), stallAccess, storeMapper);
+        InfoUser owner = new InfoUser();
+        owner.setUserType("4");
+        when(userMapper.selectById(101L)).thenReturn(owner);
+        when(stallAccess.resolveStoreId(101L)).thenReturn(11L);
+    }
+
+    @Test
+    void stallOwnerCannotDeleteAnotherStallsCard() {
+        InfoBankCard other = new InfoBankCard();
+        other.setId(7L);
+        other.setStoreId(12L);
+        when(cardMapper.selectById(7L)).thenReturn(other);
+
+        assertThrows(ServiceException.class, () ->
+                controller.delete(com.ruoyi.system.utils.JwtUtil.setToken("101", "test"), 7L));
+        verify(cardMapper, never()).deleteById(7L);
+    }
+
+    @Test
+    void stallCardListIsScopedByStoreId() {
+        when(cardMapper.selectList(any())).thenReturn(java.util.List.of());
+
+        controller.list(com.ruoyi.system.utils.JwtUtil.setToken("101", "test"));
+
+        @SuppressWarnings("rawtypes")
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper> query =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class);
+        verify(cardMapper).selectList(query.capture());
+        assertTrue(query.getValue().getSqlSegment().contains("store_id"));
+    }
+
+    @Test
+    void activatingStallCardLocksStallBeforeChangingActiveCards() {
+        InfoBankCard card = new InfoBankCard();
+        card.setId(7L);
+        card.setStoreId(11L);
+        when(cardMapper.selectById(7L)).thenReturn(card);
+        when(storeMapper.lockStallForPayment(11L)).thenReturn(11L);
+
+        controller.activate(com.ruoyi.system.utils.JwtUtil.setToken("101", "test"), 7L);
+
+        org.mockito.InOrder order = inOrder(storeMapper, cardMapper);
+        order.verify(storeMapper).lockStallForPayment(11L);
+        order.verify(cardMapper).selectById(7L);
+        order.verify(cardMapper, org.mockito.Mockito.times(2)).update(any(), any());
+    }
+
+    @Test
+    void firstStallCardIsCountedOnlyAfterStallLock() {
+        when(storeMapper.lockStallForPayment(11L)).thenReturn(11L);
+        when(cardMapper.selectCount(any())).thenReturn(0L);
+        SysDictData bank = new SysDictData();
+        bank.setDictLabel("银行");
+        BankCardSaveDto dto = new BankCardSaveDto();
+        dto.setBankName("银行");
+        dto.setAccountNo("123");
+        dto.setAccountName("摊位");
+
+        try (org.mockito.MockedStatic<DictUtils> dict = org.mockito.Mockito.mockStatic(DictUtils.class)) {
+            dict.when(() -> DictUtils.getDictCache("taiwan_bank_list"))
+                    .thenReturn(java.util.List.of(bank));
+            controller.add(com.ruoyi.system.utils.JwtUtil.setToken("101", "test"), dto);
+        }
+
+        org.mockito.InOrder order = inOrder(storeMapper, cardMapper);
+        order.verify(storeMapper).lockStallForPayment(11L);
+        order.verify(cardMapper).selectCount(any());
+        ArgumentCaptor<InfoBankCard> inserted = ArgumentCaptor.forClass(InfoBankCard.class);
+        order.verify(cardMapper).insert(inserted.capture());
+        assertTrue(inserted.getValue().getIsActive() == 1);
+        assertTrue(inserted.getValue().getStoreId() == 11L);
+    }
+}

+ 70 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/stall/StallControllerTest.java

@@ -6,6 +6,7 @@ import com.ruoyi.common.constant.HttpStatus;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.app.user.BusinessPhoneService;
 import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.PosStore;
 import com.ruoyi.system.service.IInfoUserService;
@@ -18,10 +19,12 @@ import org.springframework.test.util.ReflectionTestUtils;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.when;
 
 class StallControllerTest {
@@ -69,4 +72,71 @@ class StallControllerTest {
         assertNull(inserted.getValue().getPhone());
         assertEquals("4", inserted.getValue().getUserType());
     }
+
+    @Test
+    void anotherNightMarketCannotEditStallOrPaymentRoutingFields() {
+        IPosStoreService stores = mock(IPosStoreService.class);
+        IInfoUserService users = mock(IInfoUserService.class);
+        StallController controller = new StallController();
+        ReflectionTestUtils.setField(controller, "posStoreService", stores);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        PosStore existing = new PosStore();
+        existing.setId(8);
+        existing.setUserId(10L);
+        existing.setIsStall(1);
+        existing.setDelFlag("0");
+        when(stores.selectPosStoreById(8L)).thenReturn(existing);
+        InfoUser login = new InfoUser();
+        login.setUserType("3");
+        login.setStatus("0");
+        login.setDelFlag("0");
+        when(users.selectInfoUserByUserId(20L)).thenReturn(login);
+        PosStore attempted = new PosStore();
+        attempted.setId(8);
+        attempted.setIsStall(0);
+
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("merchant.store.access.denied")).thenReturn("无权限");
+            assertThrows(ServiceException.class, () ->
+                    controller.editStall(JwtUtil.setToken("20", "other-night-market"), attempted));
+        }
+        verify(stores, never()).saveOrUpdate(any(PosStore.class));
+    }
+
+    @Test
+    void owningNightMarketCanEditDescriptionButCannotChangeStallIdentity() {
+        IPosStoreService stores = mock(IPosStoreService.class);
+        IInfoUserService users = mock(IInfoUserService.class);
+        StallController controller = new StallController();
+        ReflectionTestUtils.setField(controller, "posStoreService", stores);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        PosStore existing = new PosStore();
+        existing.setId(8);
+        existing.setUserId(10L);
+        existing.setIsStall(1);
+        existing.setNightMarketId(10L);
+        existing.setDelFlag("0");
+        when(stores.selectPosStoreById(8L)).thenReturn(existing);
+        InfoUser login = new InfoUser();
+        login.setUserType("3");
+        login.setStatus("0");
+        login.setDelFlag("0");
+        when(users.selectInfoUserByUserId(10L)).thenReturn(login);
+        PosStore attempted = new PosStore();
+        attempted.setId(8);
+        attempted.setBriefIntroduction("new description");
+        attempted.setIsStall(0);
+        attempted.setUserId(20L);
+
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("no.success")).thenReturn("ok");
+            controller.editStall(JwtUtil.setToken("10", "owner"), attempted);
+        }
+
+        ArgumentCaptor<PosStore> saved = ArgumentCaptor.forClass(PosStore.class);
+        verify(stores).saveOrUpdate(saved.capture());
+        assertEquals(1, saved.getValue().getIsStall());
+        assertEquals(10L, saved.getValue().getUserId());
+        assertEquals("new description", saved.getValue().getBriefIntroduction());
+    }
 }

+ 4 - 2
ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoBankCard.java

@@ -9,7 +9,7 @@ import java.util.Date;
 
 /**
  * 收款银行卡(031 银行卡列表化)。
- * 商家与骑手通用,可维护多张卡但同一用户同时仅一张启用;
+ * 商家、骑手与夜市摊位通用,每个归属同时仅一张启用;
  * 付款方(用户)仅能看到收款方当前启用的卡;替代 027 的 info_user 固定三字段。
  */
 @Data
@@ -18,8 +18,10 @@ public class InfoBankCard {
     /** 主键 ID,数据库自增。 */
     @TableId(type = IdType.AUTO)
     private Long id;
-    /** 归属用户 ID(商家主账号或骑手),不区分身份。 */
+    /** 普通商家主账号或骑手的归属 ID;摊位卡为 NULL。 */
     private Long userId;
+    /** 夜市摊位归属 ID;普通商家和骑手卡为 NULL,不能用夜市主账号代替。 */
+    private Long storeId;
     /** 银行名称,取字典 taiwan_bank_list 的银行中文名。 */
     private String bankName;
     /** 银行账号。 */

+ 5 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStore.java

@@ -139,8 +139,13 @@ public class PosStore
 
     /** 是否摊位 0=店铺 1=摊位 */
     @Excel(name = "是否摊位")
+    @JsonProperty(access = JsonProperty.Access.READ_ONLY) // 支付归属依赖此标志,只能由创建摊位的服务端写入。
     private Integer isStall;
 
+    /** 摊位独立接受的支付方式组 CSV;NULL 表示接受全部,普通门店不使用此列。 */
+    @JsonProperty(access = JsonProperty.Access.READ_ONLY) // 通用门店/摊位编辑接口不可越权改支付设置;专用接口显式写列。
+    private String payMethods;
+
     /** 是否关联夜市 */
     @Excel(name = "是否关联夜市")
     private Integer isNightMarket;

+ 4 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreMapper.java

@@ -48,6 +48,10 @@ public interface PosStoreMapper extends BaseMapper<PosStore>
     @Select("SELECT id FROM pos_store WHERE user_id = #{userId} AND del_flag = '0' ORDER BY id")
     List<Long> selectOwnedStoreIdsByUserId(@Param("userId") Long userId);
 
+    /** 摊位卡新增/启用的事务锁;同摊位多个主账号并发时串行化“数卡→写卡”。 */
+    @Select("SELECT id FROM pos_store WHERE id = #{storeId} AND is_stall = 1 AND del_flag = '0' FOR UPDATE")
+    Long lockStallForPayment(@Param("storeId") Long storeId);
+
     //按照评分查询门店
     @Select("SELECT *,(st_distance(point(longitude,latitude),point(#{longitude},#{latitude}))*111195/1000 ) as juli,(SELECT del_flag FROM info_user WHERE info_user.user_id = pos_store.user_id) as xxzt,(SELECT IFNULL(AVG(score),0) FROM pos_review WHERE pos_review.md_id = pos_store.id) as pingf,(SELECT IFNULL(COUNT(id),0) FROM pos_order WHERE pos_order.md_id = pos_store.id) as ddsl  FROM pos_store WHERE del_flag='0' and off_shelf='0' HAVING xxzt=0 ORDER BY pingf DESC limit #{page},10")  //查询语句
     List<PosStore> getPingfStore(@Param("longitude")BigDecimal longitude, @Param("latitude")BigDecimal latitude,@Param("page")Integer page,@Param("juli")Integer juli);  //反回结果

+ 40 - 15
ruoyi-system/src/main/java/com/ruoyi/system/service/PaymentMethodGateService.java

@@ -9,11 +9,13 @@ import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.PaymentMethodConfig;
 import com.ruoyi.system.domain.PosStoreLinePay;
 import com.ruoyi.system.domain.PosStoreOmg;
+import com.ruoyi.system.domain.PosStore;
 import com.ruoyi.system.mapper.InfoBankCardMapper;
 import com.ruoyi.system.mapper.InfoUserMapper;
 import com.ruoyi.system.mapper.PaymentMethodConfigMapper;
 import com.ruoyi.system.mapper.PosStoreLinePayMapper;
 import com.ruoyi.system.mapper.PosStoreOmgMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
 import org.springframework.stereotype.Service;
 
 import java.util.ArrayList;
@@ -27,7 +29,8 @@ import java.util.Set;
  * 支付方式闸门服务(031 可配置支付方式)——全系统唯一的支付方式可用性判定入口。
  *
  * <p>三层闸门:某支付方式可用 = 平台开关(方式×维度,缺行=开)
- * ∩ 收款方选择(info_user 选择列,NULL=全部)∩ 就绪度(门店凭证或启用银行卡)。
+ * ∩ 收款方选择(普通商家/骑手在 info_user,摊位在 pos_store;NULL=全部)
+ * ∩ 就绪度(门店凭证或对应归属的启用银行卡)。
  * 所有下单入口(用户餐饮下单、商家建单、闪送下单)与 bankInfo 必须统一调用
  * {@link #assertUsable(String, GateScope, Long, Long)},禁止在各自入口重复实现判定逻辑,
  * 保证闸门规则变化只改本类、全部入口行为一致(spec FR-004)。</p>
@@ -98,17 +101,20 @@ public class PaymentMethodGateService {
     private final InfoBankCardMapper bankCardMapper;
     private final PosStoreOmgMapper omgMapper;
     private final PosStoreLinePayMapper linePayMapper;
+    private final PosStoreMapper storeMapper;
 
     public PaymentMethodGateService(PaymentMethodConfigMapper configMapper,
                                     InfoUserMapper userMapper,
                                     InfoBankCardMapper bankCardMapper,
                                     PosStoreOmgMapper omgMapper,
-                                    PosStoreLinePayMapper linePayMapper) {
+                                    PosStoreLinePayMapper linePayMapper,
+                                    PosStoreMapper storeMapper) {
         this.configMapper = configMapper;
         this.userMapper = userMapper;
         this.bankCardMapper = bankCardMapper;
         this.omgMapper = omgMapper;
         this.linePayMapper = linePayMapper;
+        this.storeMapper = storeMapper;
     }
 
     /**
@@ -117,7 +123,7 @@ public class PaymentMethodGateService {
      * @param payType 支付方式数值(与订单 payType 同域)
      * @param scope 闸门维度
      * @param storeId 门店 ID(MERCHANT 维度线上渠道就绪度用:pos_store_omg/pos_store_line_pay 为门店级凭证)
-     * @param payeeUserId 收款方用户 ID(商家主账号;闪送下单时无收款方传 null,跳过选择与就绪度层)
+     * @param payeeUserId 收款方用户 ID(普通商家主账号;摊位传夜市主账号也不会继承其支付设置;闪送下单时无收款方传 null)
      */
     public void assertUsable(String payType, GateScope scope, Long storeId, Long payeeUserId) {
         if (!isUsable(payType, scope, storeId, payeeUserId)) {
@@ -144,7 +150,7 @@ public class PaymentMethodGateService {
             if (orderType == null || scope == GateScope.RIDER_FLASH) return true;
             if (orderType != 1L && orderType != 2L) return false;
             if (platformDisabledMap(scope).containsKey(GROUP_CASH)) return false;
-            Set<String> selected = selectedGroups(scope, payeeUserId);
+            Set<String> selected = selectedGroups(scope, storeId, payeeUserId);
             return selected == null || selected.contains(GROUP_CASH);
         }
         return isUsable(payType, scope, storeId, payeeUserId);
@@ -165,7 +171,7 @@ public class PaymentMethodGateService {
      */
     public List<PayMethodOption> listAvailable(GateScope scope, Long storeId, Long payeeUserId, Long orderType) {
         Map<String, Boolean> disabled = platformDisabledMap(scope);
-        Set<String> selected = selectedGroups(scope, payeeUserId);
+        Set<String> selected = selectedGroups(scope, storeId, payeeUserId);
         boolean cashVisible = scope == GateScope.MERCHANT && orderType != null
                 && (orderType == 1L || orderType == 2L);
         List<PayMethodOption> result = new ArrayList<>();
@@ -188,7 +194,7 @@ public class PaymentMethodGateService {
         String group = PAY_TYPE_TO_GROUP.get(payType);
         if (group == null || !SCOPE_GROUPS.get(scope).contains(group)) return false;
         if (platformDisabledMap(scope).containsKey(group)) return false;
-        Set<String> selected = selectedGroups(scope, payeeUserId);
+        Set<String> selected = selectedGroups(scope, storeId, payeeUserId);
         if (selected != null && !selected.contains(group)) return false;
         return isReady(group, scope, storeId, payeeUserId);
     }
@@ -206,12 +212,19 @@ public class PaymentMethodGateService {
         return disabled;
     }
 
-    /** 收款方接受的组集合;null/空选择返回 null 表示"全部接受"(存量零行为变化)。 */
-    private Set<String> selectedGroups(GateScope scope, Long payeeUserId) {
-        if (payeeUserId == null) return null;
-        InfoUser user = userMapper.selectById(payeeUserId);
-        if (user == null) return null;
-        String csv = scope == GateScope.MERCHANT ? user.getPayMethods() : user.getFlashPayMethods();
+    /** 收款方接受的组集合;摊位按 storeId 读,普通商家/骑手按用户读;NULL/空=全部接受。 */
+    private Set<String> selectedGroups(GateScope scope, Long storeId, Long payeeUserId) {
+        String csv;
+        PosStore store = scope == GateScope.MERCHANT && storeId != null ? storeMapper.selectById(storeId) : null;
+        if (store != null && Integer.valueOf(1).equals(store.getIsStall())) {
+            // 摊位的 pos_store.user_id 是夜市主,不能读取其个人支付选择。
+            csv = store.getPayMethods();
+        } else {
+            if (payeeUserId == null) return null;
+            InfoUser user = userMapper.selectById(payeeUserId);
+            if (user == null) return null;
+            csv = scope == GateScope.MERCHANT ? user.getPayMethods() : user.getFlashPayMethods();
+        }
         if (StringUtils.isEmpty(csv) || csv.trim().isEmpty()) return null;
         Set<String> groups = new java.util.HashSet<>();
         for (String part : csv.split(",")) {
@@ -224,6 +237,7 @@ public class PaymentMethodGateService {
     /**
      * 商家支付设置页可选集:平台开放全集(不按商家已有选择过滤——页面本身用于修改选择),
      * 就绪度按名下门店聚合——线上渠道任一门店有启用凭证即算就绪,线下转账看主账号启用卡。
+     * 摊位设置页传 ownerUserId=null、唯一 storeId,只读取该摊位的银行卡;普通商家保持主账号归属。
      * 供 GET /system/merchantPayMethods 使用;结算页口径见 {@link #listAvailable}。
      */
     public List<PayMethodOption> listAvailableForMerchantSettings(Long ownerUserId, List<Long> storeIds) {
@@ -240,8 +254,14 @@ public class PaymentMethodGateService {
                     }
                 }
             } else if (GROUP_OFFLINE.equals(group)) {
-                ready = ownerUserId != null && bankCardMapper.selectCount(
-                        new QueryWrapper<InfoBankCard>().eq("user_id", ownerUserId).eq("is_active", 1)) > 0;
+                if (ownerUserId == null && storeIds != null && storeIds.size() == 1) {
+                    // 摊位设置页传唯一 storeId;卡归属摊位而非夜市主账号。
+                    ready = bankCardMapper.selectCount(new QueryWrapper<InfoBankCard>()
+                            .eq("store_id", storeIds.get(0)).eq("is_active", 1)) > 0;
+                } else {
+                    ready = ownerUserId != null && bankCardMapper.selectCount(
+                            new QueryWrapper<InfoBankCard>().eq("user_id", ownerUserId).eq("is_active", 1)) > 0;
+                }
             } else {
                 ready = true;
             }
@@ -253,7 +273,7 @@ public class PaymentMethodGateService {
     }
 
     /**
-     * 就绪度判定:MERCHANT 维度线上渠道看门店级凭证、线下转账看商家启用卡;
+     * 就绪度判定:MERCHANT 维度线上渠道看门店级凭证、线下转账按普通商家或摊位归属看启用卡;
      * RIDER_FLASH 维度仅线下转账在骑手身份已知时要求启用卡。
      */
     private boolean isReady(String group, GateScope scope, Long storeId, Long payeeUserId) {
@@ -268,6 +288,11 @@ public class PaymentMethodGateService {
             case GROUP_LINE_PAY:
                 return storeId != null && storeCredentialReady(group, storeId);
             case GROUP_OFFLINE:
+                PosStore store = storeId == null ? null : storeMapper.selectById(storeId);
+                if (store != null && Integer.valueOf(1).equals(store.getIsStall())) {
+                    return bankCardMapper.selectCount(new QueryWrapper<InfoBankCard>()
+                            .eq("store_id", storeId).eq("is_active", 1)) > 0;
+                }
                 return payeeUserId != null && bankCardMapper.selectCount(
                         new QueryWrapper<InfoBankCard>().eq("user_id", payeeUserId).eq("is_active", 1)) > 0;
             default:

+ 43 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/StallPaymentAccessService.java

@@ -0,0 +1,43 @@
+package com.ruoyi.system.service;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import org.springframework.stereotype.Service;
+
+/**
+ * 摊位支付资料的唯一授权入口。摊位主关联由后台建立,不能信任客户端传入的门店 ID;
+ * 同摊位的多个有效摊位主解析到同一个 store_id,自然共享设置与银行卡。
+ */
+@Service
+public class StallPaymentAccessService {
+    private final InfoUserMapper userMapper;
+    private final PosStoreMapper storeMapper;
+
+    public StallPaymentAccessService(InfoUserMapper userMapper, PosStoreMapper storeMapper) {
+        this.userMapper = userMapper;
+        this.storeMapper = storeMapper;
+    }
+
+    public Long resolveStoreId(Long loginUserId) {
+        InfoUser user = loginUserId == null ? null : userMapper.selectInfoUserByUserId(loginUserId);
+        if (user == null || !"4".equals(user.getUserType())
+                || !"0".equals(user.getStatus()) || !"0".equals(user.getDelFlag())
+                || user.getStoreId() == null) {
+            throw denied();
+        }
+        PosStore store = storeMapper.selectPosStoreById(user.getStoreId());
+        if (store == null || !Integer.valueOf(1).equals(store.getIsStall())
+                || !"0".equals(store.getDelFlag())) {
+            throw denied();
+        }
+        return user.getStoreId();
+    }
+
+    private ServiceException denied() {
+        return new ServiceException(MessageUtils.message("merchant.store.access.denied"));
+    }
+}

+ 1 - 0
ruoyi-system/src/main/resources/mapper/chanting/PosStoreMapper.xml

@@ -30,6 +30,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="menuPhoto"    column="menu_photo"    />
         <result property="offShelf"    column="off_shelf"    />
         <result property="isStall"    column="is_stall"    />
+        <result property="payMethods"    column="pay_methods"    />
         <result property="isNightMarket"    column="is_night_market"    />
         <result property="nightMarketId"    column="night_market_id"    />
         <result property="invoiceExempt"    column="invoice_exempt"    />

+ 34 - 1
ruoyi-system/src/test/java/com/ruoyi/system/service/PaymentMethodGateServiceTest.java

@@ -6,6 +6,7 @@ import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.system.domain.InfoBankCard;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.PaymentMethodConfig;
+import com.ruoyi.system.domain.PosStore;
 import com.ruoyi.system.domain.PosStoreLinePay;
 import com.ruoyi.system.domain.PosStoreOmg;
 import com.ruoyi.system.mapper.InfoBankCardMapper;
@@ -13,6 +14,7 @@ import com.ruoyi.system.mapper.InfoUserMapper;
 import com.ruoyi.system.mapper.PaymentMethodConfigMapper;
 import com.ruoyi.system.mapper.PosStoreLinePayMapper;
 import com.ruoyi.system.mapper.PosStoreOmgMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.common.utils.spring.SpringUtils;
 import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.junit.jupiter.api.AfterAll;
@@ -46,6 +48,7 @@ class PaymentMethodGateServiceTest {
     private InfoBankCardMapper bankCardMapper;
     private PosStoreOmgMapper omgMapper;
     private PosStoreLinePayMapper linePayMapper;
+    private PosStoreMapper storeMapper;
     private static ConfigurableListableBeanFactory originalBeanFactory;
 
     @BeforeAll
@@ -81,7 +84,8 @@ class PaymentMethodGateServiceTest {
         bankCardMapper = mock(InfoBankCardMapper.class);
         omgMapper = mock(PosStoreOmgMapper.class);
         linePayMapper = mock(PosStoreLinePayMapper.class);
-        service = new PaymentMethodGateService(configMapper, userMapper, bankCardMapper, omgMapper, linePayMapper);
+        storeMapper = mock(PosStoreMapper.class);
+        service = new PaymentMethodGateService(configMapper, userMapper, bankCardMapper, omgMapper, linePayMapper, storeMapper);
         // 默认:平台无任何开关行(缺行=开),避免各用例重复 stub
         when(configMapper.selectList(any())).thenReturn(List.of());
     }
@@ -108,6 +112,35 @@ class PaymentMethodGateServiceTest {
                 service.assertUsable("1", PaymentMethodGateService.GateScope.MERCHANT, null, 7L));
     }
 
+    @Test
+    void stallSelectionDoesNotInheritNightMarketSelection() {
+        PosStore stall = new PosStore();
+        stall.setId(11);
+        stall.setIsStall(1);
+        stall.setPayMethods("COD");
+        when(storeMapper.selectById(11L)).thenReturn(stall);
+        when(userMapper.selectById(7L)).thenReturn(merchant("LINE_PAY"));
+
+        assertDoesNotThrow(() -> service.assertUsable("1", PaymentMethodGateService.GateScope.MERCHANT, 11L, 7L));
+        assertThrows(ServiceException.class, () ->
+                service.assertUsable("3", PaymentMethodGateService.GateScope.MERCHANT, 11L, 7L));
+    }
+
+    @Test
+    void stallOfflineTransferRequiresItsOwnActiveCard() {
+        PosStore stall = new PosStore();
+        stall.setId(11);
+        stall.setIsStall(1);
+        when(storeMapper.selectById(11L)).thenReturn(stall);
+        when(bankCardMapper.selectCount(any())).thenAnswer(invocation -> {
+            String sql = invocation.<com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<InfoBankCard>>getArgument(0)
+                    .getSqlSegment();
+            return sql.contains("store_id") && !sql.contains("user_id") ? 1L : 0L;
+        });
+
+        assertDoesNotThrow(() -> service.assertUsable("6", PaymentMethodGateService.GateScope.MERCHANT, 11L, 7L));
+    }
+
     @Test
     void platformDisabledRejectsWithI18nMessage() {
         when(configMapper.selectList(any()))

+ 23 - 0
ruoyi-system/src/test/java/com/ruoyi/system/service/PosStorePaymentBindingTest.java

@@ -0,0 +1,23 @@
+package com.ruoyi.system.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.ruoyi.system.domain.PosStore;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class PosStorePaymentBindingTest {
+    @Test
+    void genericStoreInputCannotChangeStallPaymentSelection() throws Exception {
+        PosStore input = new ObjectMapper().readValue(
+                "{\"id\":11,\"payMethods\":\"COD\"}", PosStore.class);
+        assertNull(input.getPayMethods());
+    }
+
+    @Test
+    void genericStoreInputCannotSwitchPaymentRoutingType() throws Exception {
+        PosStore input = new ObjectMapper().readValue(
+                "{\"id\":11,\"isStall\":0}", PosStore.class);
+        assertNull(input.getIsStall());
+    }
+}

+ 104 - 0
ruoyi-system/src/test/java/com/ruoyi/system/service/StallPaymentAccessServiceTest.java

@@ -0,0 +1,104 @@
+package com.ruoyi.system.service;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class StallPaymentAccessServiceTest {
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+    private InfoUserMapper userMapper;
+    private PosStoreMapper storeMapper;
+    private StallPaymentAccessService service;
+
+    @BeforeAll
+    static void messages() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beans = new DefaultListableBeanFactory();
+        StaticMessageSource messages = new StaticMessageSource();
+        messages.addMessage("merchant.store.access.denied", Locale.getDefault(), "无摊位支付设置权限");
+        beans.registerSingleton("messageSource", messages);
+        new SpringUtils().postProcessBeanFactory(beans);
+    }
+
+    @AfterAll
+    static void restore() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @BeforeEach
+    void setUp() {
+        userMapper = mock(InfoUserMapper.class);
+        storeMapper = mock(PosStoreMapper.class);
+        service = new StallPaymentAccessService(userMapper, storeMapper);
+    }
+
+    private InfoUser stallOwner(String status, Long storeId) {
+        InfoUser user = new InfoUser();
+        user.setUserType("4");
+        user.setStatus(status);
+        user.setDelFlag("0");
+        user.setStoreId(storeId);
+        return user;
+    }
+
+    @Test
+    void twoOwnersLinkedToSameStallResolveSamePaymentOwner() {
+        PosStore stall = new PosStore();
+        stall.setId(11);
+        stall.setIsStall(1);
+        stall.setDelFlag("0");
+        when(storeMapper.selectPosStoreById(11L)).thenReturn(stall);
+        when(userMapper.selectInfoUserByUserId(101L)).thenReturn(stallOwner("0", 11L));
+        when(userMapper.selectInfoUserByUserId(102L)).thenReturn(stallOwner("0", 11L));
+
+        assertEquals(11L, service.resolveStoreId(101L));
+        assertEquals(11L, service.resolveStoreId(102L));
+    }
+
+    @Test
+    void disabledOwnerCannotAccessLinkedStall() {
+        when(userMapper.selectInfoUserByUserId(101L)).thenReturn(stallOwner("1", 11L));
+        assertThrows(ServiceException.class, () -> service.resolveStoreId(101L));
+    }
+
+    @Test
+    void deletedOrNonStallStoreCannotBeUsed() {
+        when(userMapper.selectInfoUserByUserId(101L)).thenReturn(stallOwner("0", 11L));
+        PosStore regular = new PosStore();
+        regular.setId(11);
+        regular.setIsStall(0);
+        regular.setDelFlag("0");
+        when(storeMapper.selectPosStoreById(11L)).thenReturn(regular);
+        assertThrows(ServiceException.class, () -> service.resolveStoreId(101L));
+        regular.setIsStall(1);
+        regular.setDelFlag("1");
+        assertThrows(ServiceException.class, () -> service.resolveStoreId(101L));
+    }
+
+    @Test
+    void nightMarketOwnerCannotResolveAnyStallPaymentOwner() {
+        InfoUser nightMarket = stallOwner("0", 11L);
+        nightMarket.setUserType("3");
+        when(userMapper.selectInfoUserByUserId(101L)).thenReturn(nightMarket);
+        assertThrows(ServiceException.class, () -> service.resolveStoreId(101L));
+    }
+}

+ 2 - 0
specs/001-food-stall/spec.md

@@ -46,6 +46,8 @@
 - 创建其他摊位主
 - 管理摊位级别的设置
 
+支付设置例外(2026-09-30):同一摊位的所有启用摊位主可以共同维护该摊位接受的支付方式与收款银行卡;设置归属摊位,不归属单个摊位主。不能操作其他摊位,夜市用户不代管支付设置;详见 `specs/031-pay-method-config/spec.md` US8。
+
 ### 夜市用户(userType=3)— 只读 + 管理权
 
 - 可查看所有摊位的数据(订单、营收)

+ 10 - 2
specs/031-pay-method-config/contracts/api.md

@@ -69,7 +69,7 @@ Body: { items: [ { methodCode, scope, enabled } ] }
 GET /system/merchantPayMethods
 ```
 
-返回:`{ available: [ {methodCode, methodName, ready} ], selected: ["COD", ...] }`——available = 平台商家维度开放集合(附就绪度标记)**追加 CASH 项(2026-09-20 变更:无平台开关、ready 恒 true,是否可用由商家勾选决定)**,selected = 商家当前选择(NULL 视为全部,返回时展开为全部可用项含 CASH)。**就绪度口径(2026-09-29 变更)**:不再传 null(此前 CARD_OMG/LINE_PAY 恒 false),改为 `listAvailableForMerchantSettings(ownerUserId, 门店IDs)`——线上渠道按商家名下门店聚合(任一门店有启用凭证即 ready=true)、线下转账看主账号启用卡、COD/现金恒 true;前端对 ready=false 项渲染「未就绪」标签。
+返回:`{ available: [ {methodCode, payType, ready} ], selected: ["COD", ...] }`——available = 平台商家维度开放集合(附就绪度标记)**追加 CASH 项(2026-09-20 变更:无平台开关、ready 恒 true,是否可用由商家勾选决定)**,selected = 商家当前选择(NULL 视为全部,返回空数组,由前端展示为全选)。**就绪度口径(2026-09-29 变更)**:普通商家调用 `listAvailableForMerchantSettings(ownerUserId, 门店IDs)`——线上渠道按商家名下门店聚合(任一门店有启用凭证即 ready=true)、线下转账看主账号启用卡、COD/现金恒 true;前端对 ready=false 项渲染「未就绪」标签。
 
 ### 2.2 保存选择
 
@@ -116,7 +116,7 @@ POST /system/flashDelivery/rider/orders/{id}/confirmPayment
 
 ## 5. 027 兼容改造
 
-`GET /chanting/store/bankInfo?id={storeId}` —— **路径、匿名、返回结构(bankAccountName/bankName/bankAccountNo 三字段或 data=null)完全不变**:
+`GET /chanting/store/bankInfo?id={storeId}` —— **路径、匿名、返回结构(accountName/bankName/accountNo 三字段或 data=null)完全不变**:
 
 - 数据源:info_user 三字段 → `info_bank_card` 该商家 is_active=1 的卡
 - 可见性:原"data != null 才显示"→ 闸门(OFFLINE_TRANSFER 平台开 ∩ 商家接受 ∩ 存在启用卡)不满足时返回 data=null,老客户端自然隐藏
@@ -138,3 +138,11 @@ GET /system/storePayMethods?storeId={storeId}&type={type}    // type 可选(20
 - **现金项(2026-09-20 变更)**:`type=1 或 2` 且商家接受现金(勾选含 CASH 或未设置)时,追加 `{methodCode:"CASH", payType:"4", ready:true}`;`type=0 外送` 或不传 `type` 不返回现金(老客户端零变化)。
 - 与 bankInfo 关系:本接口管"选项显隐",bankInfo 仍管"转账信息内容"(结构不变,第 5 节)。
 - **现金下单语义**:现金仅接受 `type∈{1,2}`;订单创建后 payStatus 置 1(同到付线下收款语义,直接进入"进行中",不经"待付款");`type=0` 直传现金被闸门拒绝(`pay.method.not.available`)。
+
+## 8. 夜市摊位支付设置增量(2026-09-30,US8)
+
+- `GET/PUT /system/merchantPayMethods`:有效 `userType=4` 摊位主通过服务端 `info_user.store_id` 定位唯一未删除摊位,读取/写入 `pos_store.pay_methods`;`GET` 的线上就绪度只看该摊位凭证,线下转账只看该摊位启用卡。`userType=3` 夜市主不可调用。
+- `GET/POST/PUT/DELETE /system/bankCard` 与 `PUT /system/bankCard/activate/{id}`:摊位主仅操作 `info_bank_card.store_id=本摊位` 的卡,入参不得指定归属;同摊位多个有效摊位主共享。普通商家和骑手仍按原 `user_id` 归属。
+- 结算、用户下单与 `bankInfo`:MERCHANT 维度若门店 `is_stall=1`,支付选择读 `pos_store.pay_methods`,线下转账就绪度和返回卡读 `info_bank_card.store_id`;普通门店保持旧路径。
+- 停用/删除摊位主、缺失关联或已删除/非摊位门店均拒绝设置操作;通用门店/摊位编辑 JSON 不接受 `payMethods` 或 `isStall`,`editStall` 仅允许夜市主编辑自己的摊位并保留数据库中的支付路由归属字段。
+- 摊位卡新增及启用接口在事务内先锁对应摊位行,再计数或切换启用卡;同摊位多个摊位主的并发请求按摊位串行,普通商家/骑手旧路径不变。

+ 12 - 0
specs/031-pay-method-config/data-model.md

@@ -91,3 +91,15 @@ WHERE user_id IS NOT NULL AND bank_name IS NOT NULL AND bank_name <> ''
 - 无 `payment_method_config` 行:现金无平台开关(`allGroups()` 不含 CASH,平台矩阵页不渲染)。
 - 无就绪度要求(同 COD,恒满足)。
 - 订单侧:`pos_order.pay_type=4` 与 `type∈{1,2}` 组合即现金自取/堂食单,`pay_status=1`(同到付线下收款语义),无新字段。
+
+## 9. 夜市摊位独立归属(2026-09-30,US8)
+
+| 表/列 | 类型 | 规则 |
+|---|---|---|
+| `pos_store.pay_methods` | VARCHAR(128) NULL | 仅 `is_stall=1` 使用,NULL/空=全部;普通商家仍读 `info_user.pay_methods` |
+| `info_bank_card.store_id` | BIGINT NULL,索引 | 摊位卡填写此列,`user_id` 必须为空 |
+| `info_bank_card.user_id` | BIGINT NULL | 普通商家和骑手卡保留此列,`store_id` 必须为空 |
+
+摊位主 `info_user.store_id` 是授权关联,不是银行卡归属。夜市主 `pos_store.user_id` 只表示创建/所有权,不参与摊位支付选择和收款卡判定。存量夜市主卡不自动复制到摊位。SQL 见 `updatesql/sql.md` 的 US8 节,需在代码部署前手工执行。通用门店编辑接口禁止绑定 `pay_methods`,仅专用支付设置接口写入该列。
+
+同一摊位可能有多个有效摊位主:新增首张卡和切换启用卡前,在同一事务内对对应 `pos_store` 行执行 `SELECT ... FOR UPDATE`,再检查/修改银行卡,保证并发请求按摊位串行处理。普通商家和骑手保持原卡管理事务逻辑。

+ 2 - 0
specs/031-pay-method-config/plan.md

@@ -82,5 +82,7 @@ E:\QtwCode\foodie\foodie-store\src\views\PayMethodSettings.vue   # 新:商家支
 
 ## 变更记录
 
+- **2026-09-30 夜市摊位支付设置(US8)**:普通商家继续使用 `info_user.pay_methods` 与用户归属银行卡;摊位新增 `pos_store.pay_methods`(NULL=全部)和 `info_bank_card.store_id`(摊位卡,`user_id` 为空)。摊位主仅凭有效登录账号的 `store_id` 解析摊位,校验账号启用、未删除、摊位未删除且 `is_stall=1`;夜市主无摊位设置权限。闸门在 MERCHANT 维度根据 `store_id` 判断摊位归属,支付方式选择和线下转账就绪度分别读摊位字段和摊位启用卡;`bankInfo` 同一归属规则。商家端复用支付设置页并向摊位主显示菜单。通用门店/摊位 JSON 不可改 `payMethods` 和 `isStall`,`editStall` 校验夜市主归属并固定支付路由字段。摊位卡新增和启用在事务内先以 `pos_store` 行锁串行化,避免多位摊位主并发产生两张首启用卡。SQL 仅登记在 `updatesql/sql.md`,由开发者手工执行后再部署代码。验证:访问隔离、同摊位共享、跨摊位隔离、结算/下单闸门与 `bankInfo`,并回归普通商家和骑手。
+
 - **2026-09-20 自取/堂食现金支付(spec US7)**:现金(payType=4)从"完全不管控、仅商家建单"调整为"纳入商家勾选(info_user.pay_methods 新增 CASH 组)、仍无平台开关与就绪度";可用场景扩展到用户自取/堂食下单(外送单直传现金被闸门拒绝)。闸门新增带 orderType 上下文的 `assertUsableForOrder` 重载,原四处调用点行为不变。无 DDL。任务见 tasks.md Phase 10(T026-T030)。
 - **2026-09-28 商家PC联调修复与 DDL 补登记**:商家端联调暴露四项问题当日修复——①foodie-store 侧边菜单缺「支付设置」入口(T031);②未配置商家(NULL)展示为全不勾,改为默认全选展示对齐「NULL=接受全部」口径(T032);③银行名称下拉误调不存在的 `/system/bankList`,改调 `/infouser/user/bankList`(T033);④保存 SQL 写入 info_user 不存在的 update_time 列致 Unknown column,商家/骑手两处移除(T034)。同日补登记 031 全套 DDL 至 updatesql/sql.md(原误只写在 data-model.md 从未入库,已执行测试库并完成 027 存量卡迁移);清理 6 文件 17 处无效 `@Auth(session=true)` 标注(947867e)。部署链提醒:商家PC 测试站须以 .env.test 构建(曾误用生产 API 基址 api.awayqtw.com 导致 401 与行为漂移)。

+ 8 - 0
specs/031-pay-method-config/quickstart.md

@@ -72,3 +72,11 @@ WHERE bank_name<>'' AND bank_account_no<>'' AND bank_account_name<>''
 4. 外送订单(type=0)直传 paymentMethod=4 下单 → 拒绝,返回国际化"该支付方式当前不可用"
 5. 商家取消勾选现金后:结算页(type=1/2)不再返回现金项;绕过界面直传现金下单同样被拒
 6. 未做任何设置的存量商家(pay_methods 为 NULL):自取/堂食现金默认可选(等于"全部接受"),其余行为零变化
+
+## 8. 夜市摊位支付归属(SC-008,2026-09-30)
+
+1. 先在测试库手工执行 `updatesql/sql.md` 的 US8 DDL;准备同一夜市下 A、B 两摊位,A 关联两个启用摊位主,B 关联另一摊位主。
+2. A 的摊位主在 foodie-store 修改方式并新增/启用卡;A 的另一摊位主重新进入页面应看到相同内容,B 的设置和卡不变。
+3. A 的用户结算页、实际下单校验、`bankInfo?id=A` 只采用 A 的选择及启用卡;B 各自独立;普通商家连锁门店和骑手原路径不变。
+4. 停用 A 的摊位主、取消其 `store_id` 关联,或改用夜市主账号调用设置接口,均应拒绝。通用门店编辑请求即使附带 `payMethods`、`isStall` 也不得改变摊位支付归属;另一个夜市主调用 `editStall` 修改 A 必须拒绝。
+5. A 的两个摊位主同时新增首张银行卡,只能出现一张启用卡;并发切换启用卡后,A 仍最多一张启用卡,B 不受影响。

+ 28 - 6
specs/031-pay-method-config/spec.md

@@ -39,7 +39,7 @@
 
 ### User Story 2 - 商家选择接受的支付方式 (Priority: P1)
 
-商家在商家端 PC(foodie-store)的支付设置页,勾选自己接受的支付方式,并可管理收款银行卡(增删、启用某一张)。选择在**商家主账号级**生效,连锁门店共享同一配置(与 027 银行信息语义一致)。商家从未设置(或清空)时默认接受全部启用方式,**存量商家行为零变化**。
+普通商家在商家端 PC(foodie-store)的支付设置页,勾选自己接受的支付方式,并可管理收款银行卡(增删、启用某一张)。选择在**商家主账号级**生效,连锁门店共享同一配置(与 027 银行信息语义一致);夜市摊位按 US8 独立设置。商家从未设置(或清空)时默认接受全部启用方式,**存量商家行为零变化**。
 
 **Why this priority**: 三层闸门的第二层,是商家侧的核心诉求;与平台开关同为基础能力。
 
@@ -137,6 +137,24 @@
 
 ---
 
+### User Story 8 - 夜市摊位独立支付设置(Priority: P1,2026-09-30 新增)
+
+夜市账号创建的每个摊位独立选择接受的支付方式并维护自己的收款银行卡;关联该摊位的所有启用摊位主(userType=4)可在商家端共同查看、修改同一份设置。夜市账号(userType=3)不设置摊位支付方式,也不代管摊位银行卡。普通商家及其分管账号仍沿用商家主账号级共享设置。
+
+**Independent Test**: 夜市下 A、B 两个摊位分别设置不同支付方式和银行卡;A 的任一有效摊位主修改后,A 的其他摊位主和用户结算页看到相同结果,B 不受影响;夜市账号、停用的摊位主及 B 的摊位主均不能改 A 的设置。
+
+**Acceptance Scenarios**:
+
+1. **Given** 同一摊位有多个启用摊位主,**When** 任一摊位主修改支付方式或启用收款卡,**Then** 其他摊位主看到同一结果,付款方只看到该摊位当前启用的卡。
+2. **Given** 两个摊位属于同一夜市,**When** A 摊位修改设置,**Then** B 摊位的支付方式与银行卡不变;夜市账号自身的旧银行卡或支付方式不自动继承给任何摊位。
+3. **Given** 摊位主被停用、删除、未关联摊位或提交其他摊位标识,**When** 请求支付设置或银行卡接口,**Then** 后端拒绝,且不读写其他摊位的数据。
+4. **Given** 摊位尚未设置支付方式,**When** 用户打开该摊位结算页或下单,**Then** 仍按“未设置=接受平台开放且就绪的方式”处理;线下转账必须等该摊位有启用银行卡才就绪。
+5. **Given** 一笔用户订单包含多个摊位,**When** 使用某支付方式下单,**Then** 每个摊位都独立通过支付闸门才允许创建订单;线上 OMG/LINE Pay 凭证仍按各摊位现有门店级配置判定。
+
+**Boundary**: 本次不增加“指定财务负责人”或摊位主之间的支付权限分级。摊位钱包归属与本支付设置独立,改变设置不迁移钱包余额或历史订单。
+
+---
+
 ### Edge Cases
 
 - 平台关闭某方式时,已存在但未支付的旧订单如何处理?→ 下单校验(US3)在创建时点生效;已创建订单不追溯改动。
@@ -147,6 +165,7 @@
 - 现金自取/堂食订单的到店收款为线下行为,平台不追踪现金到账,不阻塞履约(同到付语义)。
 - 到付(1)纳入管控且默认开。
 - 老客户端(不感知新字段/新逻辑):一律由后端校验拒绝并返回国际化提示;银行信息查询接口保持原结构兼容。
+- 夜市摊位支付设置必须按摊位归属,不得因 `pos_store.user_id` 指向夜市账号而读取夜市账号的选择或银行卡;普通商家仍按原主账号归属。
 
 ## Requirements *(mandatory)*
 
@@ -154,29 +173,31 @@
 
 - **FR-001**: 系统 MUST 提供平台级支付方式开关,按"支付方式 × 维度(商家餐饮单 / 骑手闪送)"独立控制,默认全部开启。
 - **FR-002**: 某支付方式对一笔订单可用 MUST 满足三层闸门:平台开关开启 ∩ 收款方接受 ∩ 就绪度满足(线上渠道有凭证、线下转账有启用银行卡)。**例外**:现金(4)仅适用收款方接受层(无平台开关、无就绪度),且仅限商家建单与用户自取/堂食场景(FR-006)。
-- **FR-003**: 商家 MUST 能在商家端选择接受的支付方式,选择在主账号级生效(连锁共享);未设置或清空时 MUST 等同接受全部(存量行为零变化)。
+- **FR-003**: 普通商家 MUST 能在商家端选择接受的支付方式,选择在主账号级生效(连锁共享);夜市摊位 MUST 按摊位独立设置,由关联的有效摊位主共同维护。未设置或清空时 MUST 等同接受全部(存量行为零变化)。
 - **FR-004**: 系统 MUST 在创建订单时校验支付方式满足闸门,不满足时以用户所用语言返回明确错误提示(兼容老 App,不静默失败)。该校验 MUST 封装为单一独立方法,作为唯一校验入口;所有下单路径(用户餐饮下单、商家建单、闪送下单)MUST 在下单时统一调用该方法,MUST NOT 在各入口重复实现校验逻辑。
 - **FR-005**: 信用卡与 Apple Pay MUST 作为同一组方式展示与控制(同开同关)。
 - **FR-006**: 到付(1)MUST 纳入平台开关管控且默认开;现金(4)MUST NOT 纳入平台开关与就绪度管控,MUST 纳入商家勾选(MERCHANT 维度收款方选择,未设置默认接受)。现金可用场景 = 商家建单(现状)+ 用户自取/堂食下单且商家接受;用户外送订单传现金 MUST 被拒绝并返回国际化提示。
 - **FR-007**: 用户闪送下单时 MUST 选择支付方式且被订单记录;可选项 = 闪送维度平台开关 ∩ 就绪度。
 - **FR-008**: 骑手 MUST 能设置接受的闪送收款方式;未设置时 MUST 默认接受平台开放的全部方式。
 - **FR-009**: 骑手抢单列表 MUST 只展示支付方式为其接受的闪送订单。
-- **FR-010**: 商家与骑手 MUST 能维护收款银行卡列表(多卡、同时仅一张启用、启用新卡自动停旧卡);付款方仅能看到收款方启用的卡。
+- **FR-010**: 普通商家、骑手及夜市摊位 MUST 能维护各自归属的收款银行卡列表(多卡、每一归属同时仅一张启用、启用新卡自动停旧卡);付款方仅能看到该归属启用的卡。摊位卡不归属单个摊位主或夜市账号。
 - **FR-011**: 027 存量银行三字段 MUST 自动迁移为一张启用卡,迁移不需人工操作;银行信息查询接口 MUST 保持原有结构返回启用卡并套用闸门。
 - **FR-012**: 线下转账就绪度 MUST 由"三字段齐全"改为"存在启用银行卡"。
 - **FR-013**: 骑手 MUST 能对闪送订单执行确认收款(未收→已收 + 操作日志);该操作 MUST NOT 阻塞送达/完成流程。
 - **FR-014**: 平台端开关设置页 MUST 提供四语言界面;商家端 PC 设置页(方式勾选 + 银行卡管理)MUST 实现多语言。
-- **FR-015**: 平台 MUST NOT 提供逐门店覆盖商家支付方式的能力(本期明确不做)。
+- **FR-015**: 普通商家连锁门店 MUST NOT 提供逐门店覆盖支付方式的能力;夜市摊位按摊位独立设置是明确例外,夜市账号不得修改摊位设置。
 - **FR-016**: 商家 App / 骑手 App 端界面本期 MUST NOT 开发(后端出接口,由 App 团队按 027 FR-010 模式另行接入)。
 - **FR-017**: 所有数据库变更 MUST 记入 `updatesql/sql.md` 由开发者统一手动执行(项目惯例)。
+- **FR-018**: 摊位支付设置与银行卡接口 MUST 根据有效摊位主的登录身份和关联摊位校验访问权,不信任客户端传入的归属 ID;停用/删除账号、未关联或已删除摊位 MUST 拒绝。
+- **FR-019**: 通用门店/摊位编辑 MUST NOT 改变用于选择支付归属的摊位标志或摊位支付设置;多个摊位主并发新增/启用银行卡时,MUST 保持每摊位最多一张启用卡。
 
 ### Key Entities *(include if feature involves data)*
 
 - **支付方式**: 业务枚举(到付、信用卡、Apple Pay、LINE Pay、现金、线下转账),信用卡与 Apple Pay 同组;现金为商家勾选组(CASH,无平台开关行)。
 - **平台支付方式开关**: 方式 × 维度(商家餐饮单/骑手闪送)× 启停状态。
-- **商家支付方式选择**: 商家主账号 → 接受的方式集合;空=全部。
+- **商家支付方式选择**: 普通商家主账号 → 连锁共享的方式集合;夜市摊位 → 该摊位独立的方式集合;空=全部。
 - **骑手闪送收款方式选择**: 骑手 → 接受的方式集合;空=平台开放全部。
-- **收款银行卡**: 归属商家或骑手,多卡、单卡启用;含开户行(台湾银行清单)、账号、户名与审计信息。
+- **收款银行卡**: 归属普通商家、骑手或夜市摊位,多卡、每归属单卡启用;含开户行(台湾银行清单)、账号、户名与审计信息。
 - **闪送订单支付信息**: 订单记录的支付方式与收款状态(未收/已收)。
 
 ## Success Criteria *(mandatory)*
@@ -190,6 +211,7 @@
 - **SC-005**: 闪送抢单列表中不再出现骑手不接受支付方式的订单(过滤准确率 100%)。
 - **SC-006**: 平台设置页与商家端设置页全部文案覆盖四语言(vi/zh/tw/en),无硬编码。
 - **SC-007**(2026-09-20 新增): 用户外送订单直传现金 100% 被拒绝并返回所用语言提示;勾选现金的商家,其自取/堂食结算页 100% 出现现金项且订单直接进入进行中(不经待付款)。
+- **SC-008**(2026-09-30 新增): 同一夜市两个摊位的支付方式与启用银行卡互不串用;同摊位多个有效摊位主读取同一份设置,跨摊位和停用账号的写入请求全部被拒绝。
 
 ## Assumptions
 

+ 7 - 0
specs/031-pay-method-config/tasks.md

@@ -178,3 +178,10 @@ description: "Task list for 031-pay-method-config"
 - 每完成一个任务或逻辑组即 commit(test-202609v2);DDL 执行时机由开发者掌握(US5 上线前必须执行迁移)
 - 闪送既有测试(`FlashDelivery*Test`)不可回归;027 商家确认收款 `confirmTransferPayment` 与 US6 骑手确认收款并存,互不影响
 - 老 App 兜底全靠后端闸门(FR-004),任何入口不得省略 assertUsable 调用
+## Phase 12(2026-09-30,US8:夜市摊位独立支付设置)
+
+- [x] T036 更新 031/001 规格与计划,登记摊位级字段及银行卡归属迁移 SQL(不执行 SQL)。
+- [x] T037 为有效摊位主访问、同摊位共享、跨摊位拒绝和支付闸门编写并运行失败测试,再实现后端权限与归属分支。
+- [x] T038 接通 `bankInfo` 的摊位启用卡、商家端摊位主菜单,补必要注释;页面复用现有四语言文案。
+- [x] T039 运行定向测试、模块构建及前端检查,核对两个仓库 diff 与部署前 SQL 手工执行要求;数据库迁移和真实并发冒烟由开发者部署前执行。
+- [x] T040 代码审查收口:通用摊位编辑授权与不可变支付归属字段;摊位首卡新增/启用串行化,补回归测试。

+ 14 - 0
updatesql/sql.md

@@ -1859,3 +1859,17 @@ INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_cla
 SELECT 2, '自部署地图', 'SELF_HOSTED', 'sys_map_distance_source', '', 'default', 'N', '0', 'admin', NOW(), '切换时与 GOOGLE 保持恰好一个默认项'
 WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'sys_map_distance_source' AND dict_value = 'SELF_HOSTED');
 ```
+
+## 2026-09-30 031 夜市摊位独立支付设置(US8)
+
+部署前由开发者手动执行;不迁移夜市主账号的支付选择或银行卡到摊位。普通商家与骑手原有 `user_id` 卡保持不变。
+
+```sql
+ALTER TABLE pos_store
+  ADD COLUMN pay_methods VARCHAR(128) DEFAULT NULL COMMENT '摊位接受的支付方式组CSV;NULL=全部,普通门店不用';
+
+ALTER TABLE info_bank_card
+  MODIFY COLUMN user_id BIGINT NULL COMMENT '普通商家主账号或骑手;摊位卡为NULL',
+  ADD COLUMN store_id BIGINT NULL COMMENT '夜市摊位ID;普通商家和骑手卡为NULL' AFTER user_id,
+  ADD KEY idx_bank_card_store (store_id);
+```