Просмотр исходного кода

修复商家分管账号业务接口及账单门店权限

补齐营业时间、优惠券、促销和评论的登录及门店归属校验;首页和日周月账单按授权门店查询,防止越权及空授权查询全量。同步回归测试、代码注释和规格接入文档。
qmj 4 дней назад
Родитель
Сommit
3c144ea6f9
29 измененных файлов с 1050 добавлено и 69 удалено
  1. 24 7
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/OperatingHoursController.java
  2. 34 10
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShPosReviewController.java
  3. 23 4
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShPromotionActivityController.java
  4. 20 0
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShPromotionCouponController.java
  5. 30 13
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShQuanyiController.java
  6. 25 9
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShSalesPromotionController.java
  7. 49 13
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShindexController.java
  8. 83 4
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/UserBillingController.java
  9. 32 0
      ruoyi-admin/src/test/java/com/ruoyi/app/mendian/MerchantBusinessEntryAccessTest.java
  10. 187 0
      ruoyi-admin/src/test/java/com/ruoyi/app/mendian/MerchantBusinessScopeTest.java
  11. 86 0
      ruoyi-admin/src/test/java/com/ruoyi/app/mendian/MerchantDashboardScopeTest.java
  12. 13 3
      ruoyi-admin/src/test/java/com/ruoyi/app/mendian/ShPromotionActivityFreeDeliveryTest.java
  13. 92 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/LegacyBillingSubaccountScopeTest.java
  14. 112 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/StoreBillingSubaccountAccessTest.java
  15. 10 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/UserBillingMapper.java
  16. 31 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/MerchantStoreAccessService.java
  17. 1 1
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionActivityServiceImpl.java
  18. 35 0
      ruoyi-system/src/main/resources/mapper/Billing/UserBillingMapper.xml
  19. 38 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/ScopedMerchantBillingMapperTest.java
  20. 21 0
      ruoyi-system/src/test/java/com/ruoyi/system/service/MerchantStoreAccessServiceTest.java
  21. 3 3
      ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PromotionActivityServiceImplFreeDeliveryTest.java
  22. 10 0
      specs/003-merchant-billing/plan.md
  23. 13 0
      specs/003-merchant-billing/spec.md
  24. 11 0
      specs/003-merchant-billing/tasks.md
  25. 18 0
      specs/022-merchant-store-subaccounts/contracts/api.md
  26. 11 0
      specs/022-merchant-store-subaccounts/plan.md
  27. 11 0
      specs/022-merchant-store-subaccounts/quickstart.md
  28. 12 2
      specs/022-merchant-store-subaccounts/spec.md
  29. 15 0
      specs/022-merchant-store-subaccounts/tasks.md

+ 24 - 7
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/OperatingHoursController.java

@@ -2,6 +2,9 @@ package com.ruoyi.app.mendian;
 
 import com.ruoyi.app.mendian.dto.AppointmentTimeDto;
 import com.ruoyi.app.utils.OperatingUtil;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.utils.AuthContext;
+import com.ruoyi.system.utils.Auth;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.core.controller.BaseController;
@@ -37,6 +40,9 @@ import java.util.stream.Collectors;
 @RequestMapping("/hours")
 public class OperatingHoursController extends BaseController
 {
+    @Autowired
+    private MerchantStoreAccessService merchantStoreAccessService;
+
     @Autowired
     private IOperatingHoursService operatingHoursService;
     @Autowired
@@ -64,11 +70,11 @@ public class OperatingHoursController extends BaseController
      * @param command
      * @return
      */
-    @Anonymous
+    @PreAuthorize("@ss.hasPermi('system:hours:edit')")
     @GetMapping("/setStoreHours")
-    public AjaxResult setStoreHours(String command){
-        if(!command.equals("我是管理员")){
-            throw new ServiceException("指令错误");
+    public AjaxResult setStoreHours(@RequestParam String command){
+        if(!"我是管理员".equals(command)){
+            throw new ServiceException(MessageUtils.message("merchant.store.access.denied"));
         }
         List<OperatingHours> list = operatingHoursService.list();
         Map<Long, List<OperatingHours>> map = list.stream().collect(Collectors.groupingBy(OperatingHours::getMdId));
@@ -104,8 +110,10 @@ public class OperatingHoursController extends BaseController
      * @return
      */
     @Anonymous
+    @Auth
     @PostMapping("/addHours")
-    public AjaxResult addHours(@RequestBody OperatingHours operatingHours){
+    public AjaxResult addHours(@RequestHeader String token, @RequestBody OperatingHours operatingHours){
+        merchantStoreAccessService.requireBusinessStoreAccess(AuthContext.requireUserId(), operatingHours.getMdId());
         Long id = operatingHoursService.insertOperatingHoursReturnId(operatingHours);
         if (id > 0) {
             Map<String, Long> data = new HashMap<>();
@@ -120,8 +128,14 @@ public class OperatingHoursController extends BaseController
      * @return
      */
     @Anonymous
+    @Auth
     @PostMapping("/setHours")
-    public AjaxResult setHours(@RequestBody OperatingHours operatingHours){
+    public AjaxResult setHours(@RequestHeader String token, @RequestBody OperatingHours operatingHours){
+        OperatingHours existing = operatingHours.getId() == null ? null
+                : operatingHoursService.selectOperatingHoursById(operatingHours.getId());
+        merchantStoreAccessService.requireUnchangedBusinessStore(AuthContext.requireUserId(),
+                existing == null ? null : existing.getMdId(), operatingHours.getMdId());
+        operatingHours.setMdId(existing.getMdId());
         return toAjax(operatingHoursService.updateOperatingHours(operatingHours));
     }
 
@@ -130,8 +144,11 @@ public class OperatingHoursController extends BaseController
      * @return
      */
     @Anonymous
+    @Auth
     @GetMapping("/delHours")
-    public AjaxResult delHours(@RequestParam Long id){
+    public AjaxResult delHours(@RequestHeader String token, @RequestParam Long id){
+        OperatingHours existing = operatingHoursService.selectOperatingHoursById(id);
+        merchantStoreAccessService.requireBusinessStoreAccess(AuthContext.requireUserId(), existing == null ? null : existing.getMdId());
         return toAjax(operatingHoursService.deleteOperatingHoursById(id));
     }
 

+ 34 - 10
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShPosReviewController.java

@@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.utils.AuthContext;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.core.controller.BaseController;
@@ -18,14 +20,12 @@ import com.ruoyi.system.domain.PosStore;
 import com.ruoyi.system.service.IPosReviewService;
 import com.ruoyi.system.service.IPosStoreService;
 import com.ruoyi.system.utils.Auth;
-import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
 import jakarta.servlet.http.HttpServletResponse;
 import java.util.List;
-import java.util.stream.Collectors;
 
 /**
  * 门店订单评论
@@ -33,6 +33,9 @@ import java.util.stream.Collectors;
 @RestController
 @RequestMapping("/system/shPosReview")
 public class ShPosReviewController extends BaseController{
+    @Autowired
+    private MerchantStoreAccessService merchantStoreAccessService;
+
     @Autowired
     private IPosReviewService posReviewService;
     @Autowired
@@ -42,18 +45,19 @@ public class ShPosReviewController extends BaseController{
     /**
      * 查询review列表
      */
+    @Anonymous
     @Auth
     @GetMapping("/list")
     public AjaxResult list(@RequestHeader String token,
                               @RequestParam Integer page,
                               @RequestParam Integer size)
     {
-        JwtUtil jwtUtil = new JwtUtil();
-        String id = jwtUtil.getusid(token);
-        List<PosStore> stores=posStoreService.list(new LambdaQueryWrapper<>(PosStore.class).eq(PosStore::getUserId,id));
-        List<Integer> mdIds=stores.stream().map(PosStore::getId).collect(Collectors.toList());
+        java.util.Set<Long> mdIds = merchantStoreAccessService.resolveBusinessScope(AuthContext.requireUserId()).storeIds();
         IPage<PosReview> palist = new Page<>(page,size);
         LambdaQueryWrapper<PosReview> Wrapper= new LambdaQueryWrapper<>();
+        if (mdIds.isEmpty()) {
+            return success(palist); // 不让空 IN 条件变成查询全平台评论。
+        }
         Wrapper.in(PosReview::getMdId,mdIds);
         IPage<PosReview> list =  posReviewService.page(palist,Wrapper);
         return success(list);
@@ -78,10 +82,11 @@ public class ShPosReviewController extends BaseController{
      */
 
     @Anonymous
+    @Auth
     @GetMapping(value = "/{id}")
-    public AjaxResult getInfo(@PathVariable("id") Long id)
+    public AjaxResult getInfo(@RequestHeader String token, @PathVariable("id") Long id)
     {
-        return success(posReviewService.selectPosReviewById(id));
+        return success(requireReview(id));
     }
 
 
@@ -90,10 +95,18 @@ public class ShPosReviewController extends BaseController{
      * 修改review
      */
 
+    @Anonymous
+    @Auth
     @Log(title = "review", businessType = BusinessType.UPDATE)
     @PutMapping
-    public AjaxResult edit(@RequestBody PosReview posReview)
+    public AjaxResult edit(@RequestHeader String token, @RequestBody PosReview posReview)
     {
+        PosReview existing = requireReview(posReview.getId());
+        merchantStoreAccessService.requireUnchangedBusinessStore(AuthContext.requireUserId(), existing.getMdId(), posReview.getMdId());
+        posReview.setMdId(existing.getMdId());
+        // 不允许修改评论对应的订单或用户,防止记录关联被转移。
+        posReview.setDdId(existing.getDdId());
+        posReview.setUserId(existing.getUserId());
         return toAjax(posReviewService.updatePosReview(posReview));
     }
 
@@ -101,10 +114,21 @@ public class ShPosReviewController extends BaseController{
      * 删除review
      */
 
+    @Anonymous
+    @Auth
     @Log(title = "review", businessType = BusinessType.DELETE)
     @DeleteMapping("/{ids}")
-    public AjaxResult remove(@PathVariable Long[] ids)
+    public AjaxResult remove(@RequestHeader String token, @PathVariable Long[] ids)
     {
+        for (Long id : ids) {
+            requireReview(id);
+        }
         return toAjax(posReviewService.deletePosReviewByIds(ids));
     }
+    private PosReview requireReview(Long id) {
+        Long userId = AuthContext.requireUserId();
+        PosReview existing = id == null ? null : posReviewService.selectPosReviewById(id);
+        merchantStoreAccessService.requireBusinessStoreAccess(userId, existing == null ? null : existing.getMdId());
+        return existing;
+    }
 }

+ 23 - 4
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShPromotionActivityController.java

@@ -2,6 +2,8 @@ package com.ruoyi.app.mendian;
 
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.utils.AuthContext;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
@@ -9,7 +11,6 @@ import com.ruoyi.system.domain.PromotionActivity;
 import com.ruoyi.system.dto.PromotionActivitySaveRequest;
 import com.ruoyi.system.service.IPromotionActivityService;
 import com.ruoyi.system.utils.Auth;
-import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 
@@ -24,6 +25,9 @@ import org.springframework.web.bind.annotation.*;
 @RequestMapping("/system/shPromotionActivity")
 public class ShPromotionActivityController extends BaseController
 {
+    @Autowired
+    private MerchantStoreAccessService merchantStoreAccessService;
+
     @Autowired
     private IPromotionActivityService promotionActivityService;
 
@@ -40,6 +44,7 @@ public class ShPromotionActivityController extends BaseController
                            @RequestParam(required = false) Integer type,
                            @RequestParam(required = false) Integer status)
     {
+        merchantStoreAccessService.requireBusinessStoreAccess(AuthContext.requireUserId(), storeId);
         IPage<PromotionActivity> pageParam = new Page<>(page, size);
         IPage<PromotionActivity> result = promotionActivityService.selectActivityPage(pageParam, storeId, type, status);
         return success(result);
@@ -53,6 +58,7 @@ public class ShPromotionActivityController extends BaseController
     @GetMapping("/{id}")
     public AjaxResult getInfo(@RequestHeader String token, @PathVariable("id") Long id)
     {
+        requireActivity(id);
         PromotionActivity activity = promotionActivityService.selectActivityWithRules(id, resolveOperator(token));
         return success(activity);
     }
@@ -65,6 +71,7 @@ public class ShPromotionActivityController extends BaseController
     @PostMapping
     public AjaxResult add(@RequestHeader String token, @RequestBody PromotionActivitySaveRequest request)
     {
+        merchantStoreAccessService.requireBusinessStoreAccess(AuthContext.requireUserId(), request.getStoreId());
         PromotionActivity activity = request.toActivity();
         return toAjax(promotionActivityService.createActivity(activity, request.getRules(), resolveOperator(token)));
     }
@@ -77,6 +84,7 @@ public class ShPromotionActivityController extends BaseController
     @PutMapping("/{id}/end")
     public AjaxResult end(@RequestHeader String token, @PathVariable("id") Long id)
     {
+        requireActivity(id);
         return toAjax(promotionActivityService.endActivity(id, resolveOperator(token)));
     }
 
@@ -88,7 +96,12 @@ public class ShPromotionActivityController extends BaseController
     @PutMapping
     public AjaxResult edit(@RequestHeader String token, @RequestBody PromotionActivitySaveRequest request)
     {
+        PromotionActivity existing = requireActivity(request.getId());
+        merchantStoreAccessService.requireUnchangedBusinessStore(AuthContext.requireUserId(), existing.getStoreId(), request.getStoreId());
         PromotionActivity activity = request.toActivity();
+        activity.setStoreId(existing.getStoreId());
+        // 活动类型决定规则和有效状态,修改不能通过切换类型绕过原规则。
+        activity.setType(existing.getType());
         return toAjax(promotionActivityService.updateActivity(activity, request.getRules(), resolveOperator(token)));
     }
 
@@ -100,15 +113,21 @@ public class ShPromotionActivityController extends BaseController
     @DeleteMapping("/{id}")
     public AjaxResult remove(@RequestHeader String token, @PathVariable("id") Long id)
     {
+        requireActivity(id);
         return toAjax(promotionActivityService.deleteActivity(id, resolveOperator(token)));
     }
 
     /**
-     * 从 token 解析操作者用户ID(@Auth 切面已完成验签,这里只取可信id)
+     * 读取 @Auth 切面已验证的操作者,不重复解析客户端 token
      */
     private Long resolveOperator(String token)
     {
-        String userId = new JwtUtil().getusid(token);
-        return userId == null ? null : Long.valueOf(userId);
+        return AuthContext.requireUserId();
+    }
+    private PromotionActivity requireActivity(Long id) {
+        Long userId = AuthContext.requireUserId();
+        PromotionActivity existing = id == null ? null : promotionActivityService.getById(id);
+        merchantStoreAccessService.requireBusinessStoreAccess(userId, existing == null ? null : existing.getStoreId());
+        return existing;
     }
 }

+ 20 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShPromotionCouponController.java

@@ -3,6 +3,8 @@ package com.ruoyi.app.mendian;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.utils.AuthContext;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
@@ -22,6 +24,9 @@ import java.util.List;
 @RequestMapping("/system/shPromotionCoupon")
 public class ShPromotionCouponController extends BaseController
 {
+    @Autowired
+    private MerchantStoreAccessService merchantStoreAccessService;
+
     @Autowired
     private IPromotionCouponBatchService promotionCouponBatchService;
 
@@ -38,6 +43,7 @@ public class ShPromotionCouponController extends BaseController
                            @RequestParam(required = false) Integer couponType,
                            @RequestParam(required = false) Integer status)
     {
+        merchantStoreAccessService.requireBusinessStoreAccess(AuthContext.requireUserId(), storeId);
         IPage<PromotionCouponBatch> pageParam = new Page<>(page, size);
         LambdaQueryWrapper<PromotionCouponBatch> queryWrapper = new LambdaQueryWrapper<>();
         queryWrapper.eq(PromotionCouponBatch::getStoreId, storeId);
@@ -62,6 +68,7 @@ public class ShPromotionCouponController extends BaseController
     @GetMapping("/{id}")
     public AjaxResult getInfo(@RequestHeader String token, @PathVariable("id") Long id)
     {
+        requireBatch(id);
         PromotionCouponBatch batch = promotionCouponBatchService.selectBatchWithRule(id);
         return success(batch);
     }
@@ -74,6 +81,7 @@ public class ShPromotionCouponController extends BaseController
     @PostMapping
     public AjaxResult add(@RequestHeader String token, @RequestBody PromotionCouponBatch batch)
     {
+        merchantStoreAccessService.requireBusinessStoreAccess(AuthContext.requireUserId(), batch.getStoreId());
         List<PromotionCouponRule> rules = batch.getRules();
         if (rules != null && !rules.isEmpty())
         {
@@ -91,6 +99,7 @@ public class ShPromotionCouponController extends BaseController
     @PutMapping("/{id}/offShelf")
     public AjaxResult offShelf(@RequestHeader String token, @PathVariable("id") Long id)
     {
+        requireBatch(id);
         return toAjax(promotionCouponBatchService.offShelfBatch(id));
     }
 
@@ -102,6 +111,9 @@ public class ShPromotionCouponController extends BaseController
     @PutMapping
     public AjaxResult edit(@RequestHeader String token, @RequestBody PromotionCouponBatch batch)
     {
+        PromotionCouponBatch existing = requireBatch(batch.getId());
+        merchantStoreAccessService.requireUnchangedBusinessStore(AuthContext.requireUserId(), existing.getStoreId(), batch.getStoreId());
+        batch.setStoreId(existing.getStoreId());
         List<PromotionCouponRule> rules = batch.getRules();
         if (rules != null && !rules.isEmpty())
         {
@@ -119,6 +131,14 @@ public class ShPromotionCouponController extends BaseController
     @DeleteMapping("/{id}")
     public AjaxResult remove(@RequestHeader String token, @PathVariable("id") Long id)
     {
+        requireBatch(id);
         return toAjax(promotionCouponBatchService.deleteBatch(id));
     }
+    /** 先查数据库归属,不能把请求参数当作授权依据。 */
+    private PromotionCouponBatch requireBatch(Long id) {
+        Long userId = AuthContext.requireUserId();
+        PromotionCouponBatch existing = id == null ? null : promotionCouponBatchService.getById(id);
+        merchantStoreAccessService.requireBusinessStoreAccess(userId, existing == null ? null : existing.getStoreId());
+        return existing;
+    }
 }

+ 30 - 13
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShQuanyiController.java

@@ -6,6 +6,8 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ruoyi.app.utils.DateUtil;
 import com.ruoyi.app.utils.GetVipqy;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.utils.AuthContext;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.core.controller.BaseController;
@@ -40,6 +42,9 @@ import java.util.stream.Collectors;
 @RestController
 @RequestMapping("/system/shQuanyi")
 public class ShQuanyiController extends BaseController {
+    @Autowired
+    private MerchantStoreAccessService merchantStoreAccessService;
+
     @Autowired
     private IVipQuanyiService vipQuanyiService;
     @Autowired
@@ -98,8 +103,7 @@ public class ShQuanyiController extends BaseController {
                            @RequestParam String language,
                            @RequestParam Long shId
     ) {
-        JwtUtil jwtUtil = new JwtUtil();
-        String id = jwtUtil.getusid(token);
+        merchantStoreAccessService.requireBusinessStoreAccess(AuthContext.requireUserId(), shId);
         IPage<VipQuanyi> iPage = new Page<>(page, size);
         LambdaQueryWrapper<VipQuanyi> queryWrapper = new LambdaQueryWrapper<>();
         queryWrapper.eq(VipQuanyi::getShId, shId);
@@ -115,9 +119,10 @@ public class ShQuanyiController extends BaseController {
      * 获取商家优惠券详细信息
      */
     @Anonymous
+    @Auth
     @GetMapping(value = "/{id}")
-    public AjaxResult getInfo(@PathVariable("id") Long id) {
-        VipQuanyi result= vipQuanyiService.selectVipQuanyiById(id);
+    public AjaxResult getInfo(@RequestHeader String token, @PathVariable("id") Long id) {
+        VipQuanyi result = requireRecord(id);
         if(result!=null){
             LambdaQueryWrapper<VipUserQuanyi> query= new LambdaQueryWrapper<>();
             query.eq(VipUserQuanyi::getQuanyiId,id);
@@ -138,12 +143,11 @@ public class ShQuanyiController extends BaseController {
     @Auth
     @PostMapping
     public AjaxResult add(@RequestHeader String token, @RequestBody VipQuanyi vipQuanyi) {
-        JwtUtil jwtUtil = new JwtUtil();
-        String id = jwtUtil.getusid(token);
-//        vipQuanyi.setShId(Long.valueOf(id));
+        merchantStoreAccessService.requireBusinessStoreAccess(AuthContext.requireUserId(), vipQuanyi.getShId());
         vipQuanyi.setCreateTime(new Date());
         vipQuanyi.setType("1");
-        vipQuanyi.setMerchantId(Long.valueOf(id));
+        // 商家归属从门店确定,不能写入分管账号 ID。
+        vipQuanyi.setMerchantId(storeService.getById(vipQuanyi.getShId()).getUserId());
         return toAjax(vipQuanyiService.insertVipQuanyi(vipQuanyi));
     }
 
@@ -153,7 +157,12 @@ public class ShQuanyiController extends BaseController {
     @Anonymous
     @Auth
     @PutMapping
-    public AjaxResult edit(@RequestBody VipQuanyi vipQuanyi) {
+    public AjaxResult edit(@RequestHeader String token, @RequestBody VipQuanyi vipQuanyi) {
+        VipQuanyi existing = requireRecord(vipQuanyi.getId());
+        merchantStoreAccessService.requireUnchangedBusinessStore(AuthContext.requireUserId(), existing.getShId(), vipQuanyi.getShId());
+        vipQuanyi.setShId(existing.getShId());
+        vipQuanyi.setType(existing.getType());
+        vipQuanyi.setMerchantId(existing.getMerchantId());
         vipQuanyi.setUpdateTime(new Date());
         return toAjax(vipQuanyiService.updateVipQuanyi(vipQuanyi));
     }
@@ -164,7 +173,11 @@ public class ShQuanyiController extends BaseController {
     @Anonymous
     @Auth
     @DeleteMapping("/{ids}")
-    public AjaxResult remove(@PathVariable Long[] ids) {
+    public AjaxResult remove(@RequestHeader String token, @PathVariable Long[] ids) {
+        // 先检查整批再删除,后面的越权 ID 不能造成前面记录已被删除。
+        for (Long id : ids) {
+            requireRecord(id);
+        }
         return toAjax(vipQuanyiService.deleteVipQuanyiByIds(ids));
     }
 
@@ -172,7 +185,7 @@ public class ShQuanyiController extends BaseController {
      * 补充优惠券商家id
      * @return
      */
-    @Anonymous
+    @PreAuthorize("@ss.hasPermi('system:quanyi:edit')")
     @GetMapping("/setMerchatId")
     public AjaxResult  setMerchatId () {
         List<VipQuanyi> list = vipQuanyiService.list(new LambdaQueryWrapper<VipQuanyi>().isNotNull(VipQuanyi::getShId).isNull(VipQuanyi::getMerchantId).eq(VipQuanyi::getType,"1"));
@@ -199,6 +212,10 @@ public class ShQuanyiController extends BaseController {
         }
         return AjaxResult.success();
     }
+    private VipQuanyi requireRecord(Long id) {
+        Long userId = AuthContext.requireUserId();
+        VipQuanyi existing = id == null ? null : vipQuanyiService.selectVipQuanyiById(id);
+        merchantStoreAccessService.requireBusinessStoreAccess(userId, existing == null ? null : existing.getShId());
+        return existing;
+    }
 }
-
-

+ 25 - 9
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShSalesPromotionController.java

@@ -6,6 +6,8 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ruoyi.app.utils.DateUtil;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.utils.AuthContext;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.core.controller.BaseController;
@@ -37,6 +39,9 @@ import java.util.List;
 @RestController
 @RequestMapping("/system/shSalesPromotion")
 public class ShSalesPromotionController  extends BaseController {
+    @Autowired
+    private MerchantStoreAccessService merchantStoreAccessService;
+
     @Autowired
     private ISalesPromotionService salesPromotionService;
 
@@ -140,8 +145,7 @@ public class ShSalesPromotionController  extends BaseController {
                            @RequestParam Long shId
     )
     {
-        JwtUtil jwtUtil = new JwtUtil();
-        String id = jwtUtil.getusid(token);
+        merchantStoreAccessService.requireBusinessStoreAccess(AuthContext.requireUserId(), shId);
         IPage<SalesPromotion> stlist = new Page<>(page,size);
         LambdaQueryWrapper<SalesPromotion> queryWrapper = new LambdaQueryWrapper<>();
         queryWrapper.eq(SalesPromotion::getDeleted,'0');
@@ -158,9 +162,9 @@ public class ShSalesPromotionController  extends BaseController {
     @Anonymous
     @Auth
     @GetMapping(value = "/{id}")
-    public AjaxResult getInfo(@PathVariable("id") Long id)
+    public AjaxResult getInfo(@RequestHeader String token, @PathVariable("id") Long id)
     {
-        SalesPromotion result= salesPromotionService.selectSalesPromotionById(id);
+        SalesPromotion result = requireRecord(id);
         if(result!=null) {
             LambdaQueryWrapper<PosOrder> query = new LambdaQueryWrapper<>();
             query.nested(w -> w.eq(PosOrder::getActivity, id).or().eq(PosOrder::getMdActivity, id));
@@ -182,9 +186,7 @@ public class ShSalesPromotionController  extends BaseController {
     @PostMapping
     public AjaxResult add(@RequestHeader String token ,@RequestBody SalesPromotion salesPromotion)
     {
-//        JwtUtil jwtUtil = new JwtUtil();
-//        String id = jwtUtil.getusid(token);
-//        salesPromotion.setShId(Long.valueOf(id));
+        merchantStoreAccessService.requireBusinessStoreAccess(AuthContext.requireUserId(), salesPromotion.getShId());
         salesPromotion.setType("1");
         return toAjax(salesPromotionService.insertSalesPromotion(salesPromotion));
     }
@@ -195,8 +197,12 @@ public class ShSalesPromotionController  extends BaseController {
     @Anonymous
     @Auth
     @PutMapping
-    public AjaxResult edit(@RequestBody SalesPromotion salesPromotion)
+    public AjaxResult edit(@RequestHeader String token, @RequestBody SalesPromotion salesPromotion)
     {
+        SalesPromotion existing = requireRecord(salesPromotion.getId());
+        merchantStoreAccessService.requireUnchangedBusinessStore(AuthContext.requireUserId(), existing.getShId(), salesPromotion.getShId());
+        salesPromotion.setShId(existing.getShId());
+        salesPromotion.setType(existing.getType());
         return toAjax(salesPromotionService.updateSalesPromotion(salesPromotion));
     }
 
@@ -207,8 +213,18 @@ public class ShSalesPromotionController  extends BaseController {
     @Anonymous
     @Auth
     @DeleteMapping("/{ids}")
-    public AjaxResult remove(@PathVariable Long[] ids)
+    public AjaxResult remove(@RequestHeader String token, @PathVariable Long[] ids)
     {
+        // 先检查整批再删除,后面的越权 ID 不能造成前面记录已被删除。
+        for (Long id : ids) {
+            requireRecord(id);
+        }
         return toAjax(salesPromotionService.deleteSalesPromotionByIds(ids));
     }
+    private SalesPromotion requireRecord(Long id) {
+        Long userId = AuthContext.requireUserId();
+        SalesPromotion existing = id == null ? null : salesPromotionService.selectSalesPromotionById(id);
+        merchantStoreAccessService.requireBusinessStoreAccess(userId, existing == null ? null : existing.getShId());
+        return existing;
+    }
 }

+ 49 - 13
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShindexController.java

@@ -19,6 +19,8 @@ import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.system.mapper.UserBillingMapper;
 import com.ruoyi.system.service.*;
 import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.AuthContext;
+import com.ruoyi.system.domain.UserBilling;
 import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
@@ -52,25 +54,59 @@ public class ShindexController extends BaseController {
     private MerchantStoreAccessService merchantStoreAccessService;
 
     @Anonymous
+    @Auth
     @GetMapping("/getshindex")
-    public AjaxResult getshindex(@RequestParam String id)
+    public AjaxResult getshindex(@RequestHeader String token, @RequestParam(required = false) String id)
     {
+        Long loginId = AuthContext.requireUserId();
+        MerchantAccessContext scope = merchantStoreAccessService.resolveBusinessScope(loginId);
+        InfoUser login = infoUserService.getById(loginId);
         JSONObject org = new JSONObject();
-        int mdsum = posStoreMapper.getshstore(Long.valueOf(id));
-        int dayorder = posOrderMapper.getshoredr(Long.valueOf(id));
-        int qbuorder = posOrderMapper.getshqbuord(Long.valueOf(id));
-        int qbshour = userBillingMapper.getshqbsum(Long.valueOf(id));
-        int qbtixian = userBillingMapper.getshtxsum(Long.valueOf(id));
-        int dtshour = userBillingMapper.getshdtsum(Long.valueOf(id));
-        org.put("stroe",mdsum);
-        org.put("dtorder",dayorder);
-        org.put("qborder",qbuorder);
-        org.put("qbshour",qbshour);
-        org.put("dtshour",dtshour);
-        org.put("ketixian",qbshour-qbtixian);
+        if ("1".equals(login.getUserType()) || "3".equals(login.getUserType())) {
+            // 兼容主账号原统计口径,但只使用认证身份,不接受客户端指定其他商家。
+            int income = userBillingMapper.getshqbsum(loginId);
+            org.put("stroe", posStoreMapper.getshstore(loginId));
+            org.put("dtorder", posOrderMapper.getshoredr(loginId));
+            org.put("qborder", posOrderMapper.getshqbuord(loginId));
+            org.put("qbshour", income);
+            org.put("dtshour", userBillingMapper.getshdtsum(loginId));
+            org.put("ketixian", income - userBillingMapper.getshtxsum(loginId));
+        } else {
+            QueryWrapper<PosOrder> orders = new QueryWrapper<>();
+            QueryWrapper<UserBilling> bills = new QueryWrapper<>();
+            if (scope.storeIds().isEmpty()) {
+                orders.apply("1 = 0");
+                bills.apply("1 = 0");
+            } else {
+                orders.in("md_id", scope.storeIds());
+                bills.in("md_id", scope.storeIds());
+            }
+            if ("5".equals(login.getUserType())) {
+                orders.eq("sh_id", scope.ownerUserId());
+                bills.eq("user_id", scope.ownerUserId());
+            } else {
+                // 摊位账单入夜市/商家名下;同门店也有骑手账单,必须排除骑手收入。
+                bills.eq("user_type", "1");
+            }
+            bills.eq("type", "0").eq("state", "0");
+            bills.select("IFNULL(SUM(amount),0) AS amount");
+            org.put("stroe", scope.storeIds().size());
+            org.put("qborder", posOrderMapper.selectCount(orders));
+            org.put("qbshour", incomeAmount(userBillingMapper.selectOne(bills)));
+            orders.apply("DATE(cretim) = CURDATE()");
+            bills.apply("DATE(cretim) = CURDATE()");
+            org.put("dtorder", posOrderMapper.selectCount(orders));
+            org.put("dtshour", incomeAmount(userBillingMapper.selectOne(bills)));
+            // 授权门店收入不是主账号可提现余额,分管/摊位不暴露主账号资金。
+            org.put("ketixian", null);
+        }
         return success(org);
     }
 
+    private double incomeAmount(UserBilling bill) {
+        return bill == null || bill.getAmount() == null ? 0 : bill.getAmount();
+    }
+
     //查询商家的订单
     @Anonymous
     @Auth

+ 83 - 4
ruoyi-admin/src/main/java/com/ruoyi/app/pay/UserBillingController.java

@@ -19,6 +19,7 @@ import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.core.page.TableDataInfo;
 import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.utils.poi.ExcelUtil;
 import com.ruoyi.system.domain.*;
@@ -70,6 +71,8 @@ public class UserBillingController extends BaseController {
     private IPosStoreService posStoreService;
     @Autowired
     private FlashDeliveryOrderMapper flashDeliveryOrderMapper;
+    @Autowired
+    private MerchantStoreAccessService merchantStoreAccessService;
 
     /**
      * 商家端账单列表
@@ -100,6 +103,8 @@ public class UserBillingController extends BaseController {
             if (mdId != null) {
                 wrapper.eq("md_id", mdId);
             }
+        } else if ("5".equals(userType)) {
+            scopeSubaccountBilling(wrapper, userId, mdId);
         } else if ("4".equals(userType)) {
             // 摊位主/门店:仅查自己关联门店的账单
             Long storeId = user.getStoreId();
@@ -115,10 +120,10 @@ public class UserBillingController extends BaseController {
             wrapper.eq("type", type);
         }
         if (startDate != null && !startDate.isEmpty()) {
-            wrapper.apply("date_format(cretim,'%Y-%m-%d %H:%i:%s') >= date_format('" + startDate + " 00:00:00','%Y-%m-%d %H:%i:%s')");
+            wrapper.apply("date_format(cretim,'%Y-%m-%d %H:%i:%s') >= date_format({0},'%Y-%m-%d %H:%i:%s')", startDate + " 00:00:00");
         }
         if (endDate != null && !endDate.isEmpty()) {
-            wrapper.apply("date_format(cretim,'%Y-%m-%d %H:%i:%s') <= date_format('" + endDate + " 23:59:59','%Y-%m-%d %H:%i:%s')");
+            wrapper.apply("date_format(cretim,'%Y-%m-%d %H:%i:%s') <= date_format({0},'%Y-%m-%d %H:%i:%s')", endDate + " 23:59:59");
         }
         wrapper.orderByDesc("id");
 
@@ -163,6 +168,8 @@ public class UserBillingController extends BaseController {
             if (mdId != null) {
                 wrapper.eq("md_id", mdId);
             }
+        } else if ("5".equals(userType)) {
+            scopeSubaccountBilling(wrapper, userId, mdId);
         } else if ("4".equals(userType)) {
             Long storeId = user.getStoreId();
             if (storeId == null) {
@@ -177,10 +184,10 @@ public class UserBillingController extends BaseController {
             wrapper.eq("type", type);
         }
         if (startDate != null && !startDate.isEmpty()) {
-            wrapper.apply("date_format(cretim,'%Y-%m-%d %H:%i:%s') >= date_format('" + startDate + " 00:00:00','%Y-%m-%d %H:%i:%s')");
+            wrapper.apply("date_format(cretim,'%Y-%m-%d %H:%i:%s') >= date_format({0},'%Y-%m-%d %H:%i:%s')", startDate + " 00:00:00");
         }
         if (endDate != null && !endDate.isEmpty()) {
-            wrapper.apply("date_format(cretim,'%Y-%m-%d %H:%i:%s') <= date_format('" + endDate + " 23:59:59','%Y-%m-%d %H:%i:%s')");
+            wrapper.apply("date_format(cretim,'%Y-%m-%d %H:%i:%s') <= date_format({0},'%Y-%m-%d %H:%i:%s')", endDate + " 23:59:59");
         }
 
         wrapper.select(
@@ -200,6 +207,23 @@ public class UserBillingController extends BaseController {
         return success(MessageUtils.message("no.success"), org);
     }
 
+    /** 列表和汇总共用分管范围:账单属于主账号,门店权限必须每次重新解析。 */
+    private void scopeSubaccountBilling(QueryWrapper<UserBilling> wrapper, Long loginUserId, Long mdId) {
+        MerchantAccessContext context = merchantStoreAccessService.resolve(loginUserId);
+        wrapper.eq("user_id", context.ownerUserId());
+        if (mdId != null) {
+            if (!context.storeIds().contains(mdId)) {
+                throw new ServiceException(MessageUtils.message("merchant.store.access.denied"));
+            }
+            wrapper.eq("md_id", mdId);
+        } else if (context.storeIds().isEmpty()) {
+            // 空授权必须显式匹配零行,不能跳过IN条件而暴露主账号的全部账单。
+            wrapper.apply("1 = 0");
+        } else {
+            wrapper.in("md_id", context.storeIds());
+        }
+    }
+
     /**
      * 填充门店名称
      */
@@ -245,6 +269,9 @@ public class UserBillingController extends BaseController {
         JwtUtil jwtUtil = new JwtUtil();
         Long id = Long.valueOf(jwtUtil.getusid(token));
         InfoUser user = infoUserService.getById(id);
+        if (user != null && "5".equals(user.getUserType())) {
+            return subaccountPeriodBilling(user, page, size, riqi, isCancel, mdId, "DAY");
+        }
         //获取日的取消订单列表
         if (isCancel != null && isCancel) {
             return getDayCancleOrderList(page, size, riqi, user);
@@ -276,6 +303,52 @@ public class UserBillingController extends BaseController {
         return success(MessageUtils.message("no.success"), org);
     }
 
+    /** 分管账号旧账单入口:明细、汇总、取消记录必须使用同一个实时授权集合。 */
+    private AjaxResult subaccountPeriodBilling(InfoUser user, Long page, Long size, String date,
+                                               Boolean isCancel, Long mdId, String period) {
+        MerchantAccessContext scope = merchantStoreAccessService.resolve(user.getUserId());
+        if (mdId != null && !scope.storeIds().contains(mdId)) {
+            throw new ServiceException(MessageUtils.message("merchant.store.access.denied"));
+        }
+        List<Long> storeIds = mdId == null ? new ArrayList<>(scope.storeIds()) : List.of(mdId);
+        QueryWrapper<PosOrder> cancelled = new QueryWrapper<PosOrder>()
+                .eq("sh_id", scope.ownerUserId()).in("state", 9, 10);
+        if (storeIds.isEmpty()) {
+            cancelled.apply("1 = 0");
+        } else {
+            cancelled.in("md_id", storeIds);
+        }
+        // 日期只作为绑定值,不能拼接进 SQL 绕过门店范围。
+        if ("MONTH".equals(period)) {
+            cancelled.apply("DATE_FORMAT(cretim, '%Y-%m') = {0}", date);
+        } else if ("WEEK".equals(period)) {
+            cancelled.apply("WEEK(cretim) = WEEK({0}) AND YEAR(cretim) = YEAR({0})", date);
+        } else {
+            cancelled.apply("DATE(cretim) = DATE({0})", date);
+        }
+        JSONObject result = new JSONObject();
+        if (Boolean.TRUE.equals(isCancel)) {
+            cancelled.orderByDesc("cretim");
+            result.put("list", posOrderService.page(new Page<PosOrder>(page, size), cancelled).getRecords());
+            return success(MessageUtils.message("no.success"), result);
+        }
+        List<UserBilling> list = userBillingMapper.selectScopedMerchantBills(scope.ownerUserId(), storeIds,
+                period, date, (page - 1) * size, size);
+        BillDTO sum = userBillingMapper.sumScopedMerchantBills(scope.ownerUserId(), storeIds, period, date);
+        ShBillingDto goods = userBillingMapper.sumScopedMerchantGoods(scope.ownerUserId(), storeIds, period, date);
+        setYhInfo(list);
+        result.put("shul", sum.getSul());
+        result.put("sum", sum.getShouru());
+        result.put("commission", sum.getCommission());
+        result.put("tax", sum.getTax());
+        result.put("behalfAmount", sum.getBehalfAmount());
+        result.put("foodAmount", goods.getFoodAmount());
+        result.put("shYh", goods.getShYh());
+        result.put("cancelCount", posOrderService.count(cancelled));
+        setShList(list, result, user);
+        return success(MessageUtils.message("no.success"), result);
+    }
+
     //按日获取取消订单列表
     public AjaxResult getDayCancleOrderList(Long page, Long size, String riqi, InfoUser user) {
         JSONObject org = new JSONObject();
@@ -310,6 +383,9 @@ public class UserBillingController extends BaseController {
         JwtUtil jwtUtil = new JwtUtil();
         Long id = Long.valueOf(jwtUtil.getusid(token));
         InfoUser user = infoUserService.getById(id);
+        if (user != null && "5".equals(user.getUserType())) {
+            return subaccountPeriodBilling(user, page, size, riqi, isCancel, mdId, "WEEK");
+        }
         //获取日的取消订单列表
         if (isCancel != null && isCancel) {
             return getWeekCancleOrderList(page, size, riqi, user);
@@ -481,6 +557,9 @@ public class UserBillingController extends BaseController {
         JwtUtil jwtUtil = new JwtUtil();
         Long id = Long.valueOf(jwtUtil.getusid(token));
         InfoUser user = infoUserService.getById(id);
+        if (user != null && "5".equals(user.getUserType())) {
+            return subaccountPeriodBilling(user, page, size, riqi, isCancel, mdId, "MONTH");
+        }
         if (isCancel != null && isCancel) {
             return getMonthCancleOrderList(page, size, riqi, user);
         }

+ 32 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/mendian/MerchantBusinessEntryAccessTest.java

@@ -0,0 +1,32 @@
+package com.ruoyi.app.mendian;
+
+import com.ruoyi.system.utils.Auth;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.bind.annotation.RequestHeader;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** 防止旧管理入口绕过登录;业务授权回归另覆盖伪造门店和记录 ID。 */
+class MerchantBusinessEntryAccessTest {
+    @Test
+    void managementMethodsRequireAuthenticatedToken() {
+        assertProtected(OperatingHoursController.class, "addHours", "setHours", "delHours");
+        assertProtected(ShPosReviewController.class, "list", "getInfo", "edit", "remove");
+        assertProtected(ShQuanyiController.class, "list", "getInfo", "add", "edit", "remove");
+        assertProtected(ShSalesPromotionController.class, "list", "getInfo", "add", "edit", "remove");
+        assertProtected(ShindexController.class, "getshindex");
+    }
+
+    private void assertProtected(Class<?> controller, String... names) {
+        for (String name : names) {
+            Method method = Arrays.stream(controller.getDeclaredMethods())
+                    .filter(m -> m.getName().equals(name)).findFirst().orElseThrow();
+            assertNotNull(method.getAnnotation(Auth.class), controller.getSimpleName() + "." + name);
+            assertTrue(Arrays.stream(method.getParameters()).anyMatch(p ->
+                    p.getType() == String.class && p.isAnnotationPresent(RequestHeader.class)), name);
+        }
+    }
+}

+ 187 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/mendian/MerchantBusinessScopeTest.java

@@ -0,0 +1,187 @@
+package com.ruoyi.app.mendian;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.*;
+import com.ruoyi.system.dto.PromotionActivitySaveRequest;
+import com.ruoyi.system.service.*;
+import com.ruoyi.system.utils.AuthContext;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.MockedStatic;
+import org.springframework.test.util.ReflectionTestUtils;
+import java.util.Set;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/** 使用真实授权校验,只替换数据库范围解析;覆盖每个管理入口的正反例。 */
+class MerchantBusinessScopeTest {
+    MerchantStoreAccessService access;
+    IPromotionCouponBatchService coupons;
+    IPromotionActivityService activities;
+    IOperatingHoursService hours;
+    IVipQuanyiService vip;
+    ISalesPromotionService sales;
+    IPosReviewService reviews;
+    ShPromotionCouponController coupon = new ShPromotionCouponController();
+    ShPromotionActivityController activity = new ShPromotionActivityController();
+    OperatingHoursController hour = new OperatingHoursController();
+    ShQuanyiController quanyi = new ShQuanyiController();
+    ShSalesPromotionController sale = new ShSalesPromotionController();
+    ShPosReviewController review = new ShPosReviewController();
+    MockedStatic<AuthContext> auth;
+    MockedStatic<MessageUtils> messages;
+
+    @BeforeEach void setup() {
+        auth = mockStatic(AuthContext.class);
+        auth.when(AuthContext::requireUserId).thenReturn(55L);
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString())).thenAnswer(i -> i.getArgument(0));
+        access = mock(MerchantStoreAccessService.class, CALLS_REAL_METHODS);
+        doReturn(new MerchantAccessContext(55L, 10L, false, Set.of(101L))).when(access).resolveBusinessScope(55L);
+        coupons = mock(IPromotionCouponBatchService.class);
+        activities = mock(IPromotionActivityService.class);
+        hours = mock(IOperatingHoursService.class);
+        vip = mock(IVipQuanyiService.class);
+        sales = mock(ISalesPromotionService.class);
+        reviews = mock(IPosReviewService.class);
+        for (Object c : new Object[]{coupon, activity, hour, quanyi, sale, review}) {
+            ReflectionTestUtils.setField(c, "merchantStoreAccessService", access);
+        }
+        ReflectionTestUtils.setField(coupon, "promotionCouponBatchService", coupons);
+        ReflectionTestUtils.setField(activity, "promotionActivityService", activities);
+        ReflectionTestUtils.setField(hour, "operatingHoursService", hours);
+        ReflectionTestUtils.setField(quanyi, "vipQuanyiService", vip);
+        ReflectionTestUtils.setField(quanyi, "vipUserQuanyiService", mock(IVipUserQuanyiService.class));
+        IPosStoreService stores = mock(IPosStoreService.class);
+        PosStore store = new PosStore(); store.setUserId(10L);
+        when(stores.getById(101L)).thenReturn(store);
+        ReflectionTestUtils.setField(quanyi, "storeService", stores);
+        ReflectionTestUtils.setField(sale, "salesPromotionService", sales);
+        ReflectionTestUtils.setField(sale, "posOrderMapper", mock(com.ruoyi.system.mapper.PosOrderMapper.class));
+        ReflectionTestUtils.setField(review, "posReviewService", reviews);
+        for (Class<?> type : new Class<?>[]{PromotionCouponBatch.class, VipQuanyi.class, VipUserQuanyi.class,
+                SalesPromotion.class, PosOrder.class, PosReview.class}) {
+            TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), type);
+        }
+        when(hours.insertOperatingHoursReturnId(any())).thenReturn(1L);
+        when(coupons.page(any(Page.class), any())).thenReturn(new Page<PromotionCouponBatch>());
+        when(activities.selectActivityPage(any(), any(), any(), any())).thenReturn(new Page<PromotionActivity>());
+        when(vip.page(any(Page.class), any())).thenReturn(new Page<VipQuanyi>());
+        when(sales.page(any(Page.class), any())).thenReturn(new Page<SalesPromotion>());
+        when(reviews.page(any(Page.class), any())).thenReturn(new Page<PosReview>());
+        recordStore(101L);
+    }
+
+    void recordStore(Long storeId) {
+        PromotionCouponBatch c = new PromotionCouponBatch(); c.setId(1L); c.setStoreId(storeId);
+        when(coupons.getById(1L)).thenReturn(c); when(coupons.selectBatchWithRule(1L)).thenReturn(c);
+        PromotionActivity a = new PromotionActivity(); a.setId(1L); a.setStoreId(storeId); a.setType(5);
+        when(activities.getById(1L)).thenReturn(a);
+        OperatingHours h = new OperatingHours(); h.setId(1L); h.setMdId(storeId);
+        when(hours.selectOperatingHoursById(1L)).thenReturn(h);
+        VipQuanyi v = new VipQuanyi(); v.setId(1L); v.setShId(storeId); v.setType("1");
+        when(vip.selectVipQuanyiById(1L)).thenReturn(v);
+        SalesPromotion s = new SalesPromotion(); s.setId(1L); s.setShId(storeId); s.setType("1");
+        when(sales.selectSalesPromotionById(1L)).thenReturn(s);
+        PosReview r = new PosReview(); r.setId(1L); r.setMdId(storeId);
+        when(reviews.selectPosReviewById(1L)).thenReturn(r);
+    }
+
+    @AfterEach void cleanup() { auth.close(); messages.close(); }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"couponList", "couponGet", "couponAdd", "couponEdit", "couponEnd", "couponDelete",
+            "activityList", "activityGet", "activityAdd", "activityEdit", "activityEnd", "activityDelete",
+            "hoursAdd", "hoursEdit", "hoursDelete", "vipList", "vipGet", "vipAdd", "vipEdit", "vipDelete",
+            "salesList", "salesGet", "salesAdd", "salesEdit", "salesDelete", "reviewGet", "reviewEdit", "reviewDelete"})
+    void eachManagementEntryAllowsAuthorizedStoreAndRejectsRevokedScope(String entry) {
+        assertDoesNotThrow(() -> request(entry));
+        doReturn(new MerchantAccessContext(55L, 10L, false, Set.of())).when(access).resolveBusinessScope(55L);
+        assertThrows(ServiceException.class, () -> request(entry), entry);
+    }
+
+    @ParameterizedTest @ValueSource(strings = {"couponEdit", "activityEdit", "hoursEdit", "vipEdit", "salesEdit", "reviewEdit"})
+    void ownRequestStoreCannotHideForeignPersistedRecord(String entry) {
+        recordStore(999L);
+        assertThrows(ServiceException.class, () -> request(entry));
+        verify(coupons, never()).updateBatch(any(), any(PromotionCouponRule.class));
+        verify(activities, never()).updateActivity(any(), any(), any());
+        verify(hours, never()).updateOperatingHours(any());
+        verify(vip, never()).updateVipQuanyi(any());
+        verify(sales, never()).updateSalesPromotion(any());
+        verify(reviews, never()).updatePosReview(any());
+    }
+
+    @Test void batchDeleteChecksAllRecordsBeforeMutation() {
+        VipQuanyi other = new VipQuanyi(); other.setId(2L); other.setShId(999L);
+        when(vip.selectVipQuanyiById(2L)).thenReturn(other);
+        assertThrows(ServiceException.class, () -> quanyi.remove("token", new Long[]{1L, 2L}));
+        verify(vip, never()).deleteVipQuanyiByIds(any());
+        PosReview otherReview = new PosReview(); otherReview.setMdId(999L);
+        when(reviews.selectPosReviewById(2L)).thenReturn(otherReview);
+        assertThrows(ServiceException.class, () -> review.remove("token", new Long[]{1L, 2L}));
+        verify(reviews, never()).deletePosReviewByIds(any());
+        SalesPromotion otherSales = new SalesPromotion(); otherSales.setShId(999L);
+        when(sales.selectSalesPromotionById(2L)).thenReturn(otherSales);
+        assertThrows(ServiceException.class, () -> sale.remove("token", new Long[]{1L, 2L}));
+        verify(sales, never()).deleteSalesPromotionByIds(any());
+    }
+
+    @Test void emptyReviewScopeDoesNotQueryAllReviews() {
+        doReturn(new MerchantAccessContext(55L, 10L, false, Set.of())).when(access).resolveBusinessScope(55L);
+        assertEquals(200, review.list("token", 1, 10).get("code"));
+        verify(reviews, never()).page(any(Page.class), any());
+    }
+
+    @Test void missingRecordFailsClosed() {
+        assertThrows(ServiceException.class, () -> coupon.getInfo("token", 99L));
+        assertThrows(ServiceException.class, () -> hour.delHours("token", 99L));
+        assertThrows(ServiceException.class, () -> review.getInfo("token", 99L));
+    }
+
+    void request(String entry) {
+        PromotionCouponBatch c = new PromotionCouponBatch(); c.setId(1L); c.setStoreId(101L);
+        PromotionActivitySaveRequest a = new PromotionActivitySaveRequest(); a.setId(1L); a.setStoreId(101L); a.setType(1);
+        OperatingHours h = new OperatingHours(); h.setId(1L); h.setMdId(101L);
+        VipQuanyi v = new VipQuanyi(); v.setId(1L); v.setShId(101L);
+        SalesPromotion s = new SalesPromotion(); s.setId(1L); s.setShId(101L);
+        PosReview r = new PosReview(); r.setId(1L); r.setMdId(101L);
+        switch (entry) {
+            case "couponList" -> coupon.list("token",1,10,101L,null,null);
+            case "couponGet" -> coupon.getInfo("token",1L);
+            case "couponAdd" -> coupon.add("token",c);
+            case "couponEdit" -> coupon.edit("token",c);
+            case "couponEnd" -> coupon.offShelf("token",1L);
+            case "couponDelete" -> coupon.remove("token",1L);
+            case "activityList" -> activity.list("token",1,10,101L,null,null);
+            case "activityGet" -> activity.getInfo("token",1L);
+            case "activityAdd" -> activity.add("token",a);
+            case "activityEdit" -> activity.edit("token",a);
+            case "activityEnd" -> activity.end("token",1L);
+            case "activityDelete" -> activity.remove("token",1L);
+            case "hoursAdd" -> hour.addHours("token",h);
+            case "hoursEdit" -> hour.setHours("token",h);
+            case "hoursDelete" -> hour.delHours("token",1L);
+            case "vipList" -> quanyi.list("token",1,10,"zh",101L);
+            case "vipGet" -> quanyi.getInfo("token",1L);
+            case "vipAdd" -> quanyi.add("token",v);
+            case "vipEdit" -> quanyi.edit("token",v);
+            case "vipDelete" -> quanyi.remove("token",new Long[]{1L});
+            case "salesList" -> sale.list("token",1,10,"zh",101L);
+            case "salesGet" -> sale.getInfo("token",1L);
+            case "salesAdd" -> sale.add("token",s);
+            case "salesEdit" -> sale.edit("token",s);
+            case "salesDelete" -> sale.remove("token",new Long[]{1L});
+            case "reviewGet" -> review.getInfo("token",1L);
+            case "reviewEdit" -> review.edit("token",r);
+            case "reviewDelete" -> review.remove("token",new Long[]{1L});
+            default -> throw new IllegalArgumentException(entry);
+        }
+    }
+}

+ 86 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/mendian/MerchantDashboardScopeTest.java

@@ -0,0 +1,86 @@
+package com.ruoyi.app.mendian;
+
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.*;
+import com.ruoyi.system.mapper.*;
+import com.ruoyi.system.service.*;
+import com.ruoyi.system.utils.AuthContext;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.*;
+import java.util.*;
+import static org.mockito.Mockito.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+class MerchantDashboardScopeTest {
+    @Mock MerchantStoreAccessService access;
+    @Mock IInfoUserService users;
+    @Mock PosStoreMapper stores;
+    @Mock PosOrderMapper orders;
+    @Mock UserBillingMapper bills;
+    @InjectMocks ShindexController controller;
+    AutoCloseable mocks;
+    MockedStatic<AuthContext> auth;
+    MockedStatic<MessageUtils> messages;
+    InfoUser user;
+    QueryWrapper<PosOrder> orderQuery;
+    QueryWrapper<UserBilling> billQuery;
+    @BeforeEach void setup() {
+        mocks = MockitoAnnotations.openMocks(this);
+        auth = mockStatic(AuthContext.class); auth.when(AuthContext::requireUserId).thenReturn(55L);
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString())).thenAnswer(i -> i.getArgument(0));
+        user = new InfoUser(); user.setUserId(55L); user.setUserType("5");
+        when(users.getById(55L)).thenReturn(user);
+        when(access.resolveBusinessScope(55L)).thenReturn(new MerchantAccessContext(55L,10L,false,Set.of(101L,102L)));
+        when(orders.selectCount(any())).thenAnswer(i -> { orderQuery=i.getArgument(0); return 1L; });
+        when(bills.selectOne(any())).thenAnswer(i -> { billQuery=i.getArgument(0); return null; });
+    }
+    @AfterEach void cleanup() throws Exception { auth.close(); messages.close(); mocks.close(); }
+    @Test void subaccountIgnoresSuppliedOwnerAndCannotSeeWithdrawableBalance() {
+        JSONObject data=(JSONObject)controller.getshindex("token","999").get("data");
+        assertEquals(2, data.getInteger("stroe"));
+        assertNull(data.get("ketixian"));
+        assertTrue(orderQuery.getSqlSegment().contains("md_id IN"));
+        assertTrue(billQuery.getSqlSegment().contains("md_id IN"));
+        assertTrue(orderQuery.getParamNameValuePairs().values().containsAll(Set.of(10L,101L,102L)));
+        assertTrue(billQuery.getParamNameValuePairs().values().containsAll(Set.of(10L,101L,102L)));
+        verify(bills,never()).getshtxsum(anyLong());
+        verify(bills,never()).getshqbsum(anyLong());
+    }
+    @Test void noAuthorizedStoresDoesNotBroadenDashboard() {
+        when(access.resolveBusinessScope(55L)).thenReturn(new MerchantAccessContext(55L,10L,false,Set.of()));
+        controller.getshindex("token",null);
+        assertTrue(orderQuery.getSqlSegment().contains("1 = 0"));
+        assertTrue(billQuery.getSqlSegment().contains("1 = 0"));
+    }
+    @Test void stallIncomeCannotIncludeRiderIncomeForSameStore() {
+        user.setUserType("4");
+        when(access.resolveBusinessScope(55L)).thenReturn(new MerchantAccessContext(55L,55L,false,Set.of(101L)));
+        controller.getshindex("token",null);
+        assertTrue(billQuery.getSqlSegment().contains("user_type ="));
+        assertTrue(billQuery.getParamNameValuePairs().containsValue("1"));
+        assertFalse(billQuery.getParamNameValuePairs().containsValue(55L));
+        verify(bills,never()).getshtxsum(anyLong());
+    }
+    @ParameterizedTest @ValueSource(strings={"1","3"})
+    void masterAndNightMarketKeepExistingTotalsButCannotChooseAnotherIdentity(String type) {
+        user.setUserType(type);
+        when(bills.getshqbsum(55L)).thenReturn(100);
+        when(bills.getshtxsum(55L)).thenReturn(30);
+        JSONObject data=(JSONObject)controller.getshindex("token","999").get("data");
+        assertEquals(70, data.getInteger("ketixian"));
+        verify(stores).getshstore(55L);
+        verify(bills,never()).getshqbsum(999L);
+        verify(orders,never()).selectCount(any());
+    }
+    @Test void disabledIdentityStopsBeforeAnyFinancialQuery() {
+        when(access.resolveBusinessScope(55L)).thenThrow(new ServiceException("merchant.account.unavailable"));
+        assertThrows(ServiceException.class, () -> controller.getshindex("token","55"));
+        verifyNoInteractions(bills,orders,stores);
+    }
+}

+ 13 - 3
ruoyi-admin/src/test/java/com/ruoyi/app/mendian/ShPromotionActivityFreeDeliveryTest.java

@@ -41,6 +41,11 @@ class ShPromotionActivityFreeDeliveryTest
     @Mock
     private IPromotionActivityService promotionActivityService;
 
+    @Mock
+    private com.ruoyi.system.service.MerchantStoreAccessService merchantStoreAccessService;
+
+    private MockedStatic<com.ruoyi.system.utils.AuthContext> identity;
+
     @InjectMocks
     private ShPromotionActivityController controller;
 
@@ -49,6 +54,8 @@ class ShPromotionActivityFreeDeliveryTest
     @BeforeEach
     void setUpMessages()
     {
+        identity = org.mockito.Mockito.mockStatic(com.ruoyi.system.utils.AuthContext.class);
+        identity.when(com.ruoyi.system.utils.AuthContext::requireUserId).thenReturn(OPERATOR_ID);
         // toAjax/error 内部使用 MessageUtils,单测无 Spring 上下文,静态mock返回key
         messages = org.mockito.Mockito.mockStatic(MessageUtils.class);
         messages.when(() -> MessageUtils.message(any(String.class)))
@@ -59,6 +66,7 @@ class ShPromotionActivityFreeDeliveryTest
     void closeMessages()
     {
         messages.close();
+        identity.close();
     }
 
     @Test
@@ -87,9 +95,8 @@ class ShPromotionActivityFreeDeliveryTest
     @Test
     void add_shouldRejectInvalidTokenBeforeService()
     {
-        AjaxResult result = controller.add("bad-token", new PromotionActivitySaveRequest());
-
-        assertEquals(500, result.get("code"));
+        identity.when(com.ruoyi.system.utils.AuthContext::requireUserId).thenThrow(new ServiceException("merchant.session.invalid"));
+        assertThrows(ServiceException.class, () -> controller.add("bad-token", new PromotionActivitySaveRequest()));
     }
 
     @Test
@@ -109,6 +116,9 @@ class ShPromotionActivityFreeDeliveryTest
         String token = JwtUtil.token(String.valueOf(OPERATOR_ID), "merchant");
         PromotionActivitySaveRequest request = new PromotionActivitySaveRequest();
         request.setId(51L);
+        PromotionActivity existing = new PromotionActivity();
+        existing.setId(51L); existing.setStoreId(1L); existing.setType(5);
+        when(promotionActivityService.getById(51L)).thenReturn(existing);
         when(promotionActivityService.updateActivity(any(PromotionActivity.class), any(), eq(OPERATOR_ID))).thenReturn(true);
 
         controller.edit(token, request);

+ 92 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LegacyBillingSubaccountScopeTest.java

@@ -0,0 +1,92 @@
+package com.ruoyi.app.pay;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.*;
+import com.ruoyi.system.domain.vo.*;
+import com.ruoyi.system.mapper.UserBillingMapper;
+import com.ruoyi.system.service.*;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.*;
+import java.util.*;
+import static org.mockito.Mockito.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+class LegacyBillingSubaccountScopeTest {
+    @Mock IInfoUserService users;
+    @Mock UserBillingMapper bills;
+    @Mock IPosOrderService orders;
+    @Mock MerchantStoreAccessService access;
+    @InjectMocks UserBillingController controller;
+    AutoCloseable mocks;
+    MockedConstruction<JwtUtil> tokens;
+    MockedStatic<MessageUtils> messages;
+    QueryWrapper<PosOrder> orderQuery;
+    @BeforeEach void setup() {
+        mocks = MockitoAnnotations.openMocks(this);
+        tokens = mockConstruction(JwtUtil.class, (jwt, ctx) -> when(jwt.getusid("token")).thenReturn("55"));
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString())).thenAnswer(i -> i.getArgument(0));
+        InfoUser user = new InfoUser(); user.setUserId(55L); user.setUserType("5");
+        when(users.getById(55L)).thenReturn(user);
+        when(access.resolve(55L)).thenReturn(new MerchantAccessContext(55L, 10L, false, Set.of(101L, 102L)));
+        when(bills.selectScopedMerchantBills(anyLong(), anyList(), anyString(), anyString(), anyLong(), anyLong())).thenReturn(List.of());
+        when(bills.sumScopedMerchantBills(anyLong(), anyList(), anyString(), anyString())).thenReturn(new BillDTO());
+        when(bills.sumScopedMerchantGoods(anyLong(), anyList(), anyString(), anyString())).thenReturn(new ShBillingDto());
+        when(orders.count(any())).thenAnswer(i -> { orderQuery = i.getArgument(0); return 0L; });
+        when(orders.page(any(Page.class), any())).thenAnswer(i -> { orderQuery = i.getArgument(1); return new Page<PosOrder>(); });
+    }
+    @AfterEach void cleanup() throws Exception { messages.close(); tokens.close(); mocks.close(); }
+    AjaxResult request(String period, Boolean cancel, Long store) {
+        return switch (period) {
+            case "DAY" -> controller.getzddaylist("token",1L,10L,"2026-11-04",cancel,store);
+            case "WEEK" -> controller.getzdmeeklist("token",1L,10L,"2026-11-04",cancel,store);
+            default -> controller.getzdmatlist("token",1L,10L,"2026-11",cancel,store);
+        };
+    }
+    @ParameterizedTest @ValueSource(strings={"DAY","WEEK","MONTH"})
+    void authorizedStoresAndOwnerUsedForListSummaryAndCancellation(String period) {
+        assertEquals(200, request(period,false,null).get("code"));
+        var ids = ArgumentCaptor.forClass(List.class);
+        verify(bills).selectScopedMerchantBills(eq(10L),ids.capture(),eq(period),anyString(),eq(0L),eq(10L));
+        assertEquals(Set.of(101L,102L), new HashSet<>(ids.getValue()));
+        verify(bills).sumScopedMerchantBills(eq(10L),eq(ids.getValue()),eq(period),anyString());
+        verify(bills).sumScopedMerchantGoods(eq(10L),eq(ids.getValue()),eq(period),anyString());
+        checkQuery();
+        assertEquals(200, request(period,true,null).get("code"));
+        checkQuery();
+    }
+    private void checkQuery() {
+        assertTrue(orderQuery.getSqlSegment().contains("md_id IN"));
+        assertTrue(orderQuery.getParamNameValuePairs().values().containsAll(Set.of(10L,101L,102L)));
+        assertFalse(orderQuery.getSqlSegment().contains("2026-11"));
+    }
+    @ParameterizedTest @ValueSource(strings={"DAY","WEEK","MONTH"})
+    void foreignStoreCannotQueryBillsOrCancelledOrders(String period) {
+        assertThrows(ServiceException.class, () -> request(period,false,999L));
+        assertThrows(ServiceException.class, () -> request(period,true,999L));
+        verifyNoInteractions(bills,orders);
+    }
+    @ParameterizedTest @ValueSource(strings={"DAY","WEEK","MONTH"})
+    void emptyScopeIsNeverFullOwnerScope(String period) {
+        when(access.resolve(55L)).thenReturn(new MerchantAccessContext(55L,10L,false,Set.of()));
+        request(period,false,null);
+        verify(bills).selectScopedMerchantBills(eq(10L),eq(List.of()),eq(period),anyString(),eq(0L),eq(10L));
+        assertTrue(orderQuery.getSqlSegment().contains("1 = 0"));
+        request(period,true,null);
+        assertTrue(orderQuery.getSqlSegment().contains("1 = 0"));
+    }
+    @ParameterizedTest @ValueSource(strings={"DAY","WEEK","MONTH"})
+    void selectedStoreIsNarrowedAndDisabledAccountIsRejected(String period) {
+        request(period,false,101L);
+        verify(bills).selectScopedMerchantBills(eq(10L),eq(List.of(101L)),eq(period),anyString(),eq(0L),eq(10L));
+        when(access.resolve(55L)).thenThrow(new ServiceException("merchant.account.unavailable"));
+        assertThrows(ServiceException.class, () -> request(period,false,null));
+    }
+}

+ 112 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/StoreBillingSubaccountAccessTest.java

@@ -0,0 +1,112 @@
+package com.ruoyi.app.pay;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.UserBilling;
+import com.ruoyi.system.service.*;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.mockito.*;
+import java.util.Set;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+
+/** 列表和汇总都必须按主账号和实时门店授权隔离,不能把分管账号当作账单归属人。 */
+class StoreBillingSubaccountAccessTest {
+    @Mock IInfoUserService users;
+    @Mock IUserBillingService bills;
+    @Mock MerchantStoreAccessService access;
+    @InjectMocks UserBillingController controller;
+    AutoCloseable mocks;
+    MockedConstruction<JwtUtil> tokens;
+    MockedStatic<MessageUtils> messages;
+    QueryWrapper<UserBilling> query;
+    InfoUser user;
+
+    @BeforeEach void setup() {
+        mocks = MockitoAnnotations.openMocks(this);
+        tokens = mockConstruction(JwtUtil.class, (jwt, ctx) -> when(jwt.getusid("token")).thenReturn("55"));
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+        user = new InfoUser(); user.setUserId(55L); user.setUserType("5");
+        when(users.getById(55L)).thenReturn(user);
+        when(access.resolve(55L)).thenReturn(new MerchantAccessContext(55L, 10L, false, Set.of(101L, 102L)));
+        when(bills.page(any(Page.class), any())).thenAnswer(call -> {
+            query = call.getArgument(1); return new Page<UserBilling>(1, 10);
+        });
+        when(bills.getOne(any())).thenAnswer(call -> { query = call.getArgument(0); return null; });
+    }
+    @AfterEach void cleanup() throws Exception { messages.close(); tokens.close(); mocks.close(); }
+
+    private AjaxResult request(boolean summary, Long store) {
+        return summary ? controller.storeBillingSummary("token", store, null, null, null)
+                : controller.storeBillingList("token", 1, 10, store, null, null, null);
+    }
+
+    @ParameterizedTest @ValueSource(booleans = {false, true})
+    void allAuthorizedStoresAreScopedToOwner(boolean summary) {
+        assertEquals(200, request(summary, null).get("code"));
+        assertTrue(query.getSqlSegment().contains("user_id ="));
+        assertTrue(query.getSqlSegment().contains("md_id IN"));
+        var values = query.getParamNameValuePairs().values();
+        assertTrue(values.containsAll(Set.of(10L, 101L, 102L)));
+        assertFalse(values.contains(55L));
+        verify(access).resolve(55L);
+    }
+
+    @ParameterizedTest @ValueSource(booleans = {false, true})
+    void selectedStoreMustBeAuthorized(boolean summary) {
+        assertEquals(200, request(summary, 101L).get("code"));
+        assertTrue(query.getSqlSegment().contains("md_id ="));
+        assertTrue(query.getParamNameValuePairs().values().containsAll(Set.of(10L, 101L)));
+        assertFalse(query.getParamNameValuePairs().containsValue(102L));
+    }
+
+    @ParameterizedTest @ValueSource(booleans = {false, true})
+    void unauthorizedStoreIsRejectedBeforeBillingQuery(boolean summary) {
+        assertThrows(ServiceException.class, () -> request(summary, 999L));
+        verifyNoInteractions(bills);
+    }
+
+    @ParameterizedTest @ValueSource(booleans = {false, true})
+    void noStoreAuthorizationCannotBecomeUnrestrictedQuery(boolean summary) {
+        when(access.resolve(55L)).thenReturn(new MerchantAccessContext(55L, 10L, false, Set.of()));
+        assertEquals(200, request(summary, null).get("code"));
+        assertTrue(query.getSqlSegment().contains("1 = 0"));
+    }
+
+    @ParameterizedTest @ValueSource(booleans = {false, true})
+    void disabledOrRevokedAccountIsRejectedBySharedAccessService(boolean summary) {
+        when(access.resolve(55L)).thenThrow(new ServiceException("merchant.account.unavailable"));
+        assertThrows(ServiceException.class, () -> request(summary, null));
+        verifyNoInteractions(bills);
+    }
+
+    @ParameterizedTest @CsvSource({"false,1", "true,1", "false,3", "true,3", "false,4", "true,4"})
+    void existingMerchantNightMarketAndStallScopesRemain(boolean summary, String role) {
+        user.setUserType(role); user.setStoreId(101L);
+        assertEquals(200, request(summary, null).get("code"));
+        assertTrue(query.getSqlSegment().contains("4".equals(role) ? "md_id =" : "user_id ="));
+        assertTrue(query.getParamNameValuePairs().containsValue("4".equals(role) ? 101L : 55L));
+        verifyNoInteractions(access);
+    }
+
+    @ParameterizedTest @ValueSource(booleans = {false, true})
+    void datesAreBoundParametersAndCannotBypassAuthorization(boolean summary) {
+        String date = "2026-11-04') OR 1=1 -- ";
+        AjaxResult result = summary ? controller.storeBillingSummary("token", null, null, date, date)
+                : controller.storeBillingList("token", 1, 10, null, null, date, date);
+        assertEquals(200, result.get("code"));
+        assertFalse(query.getSqlSegment().contains(date));
+        assertTrue(query.getParamNameValuePairs().containsValue(date + " 00:00:00"));
+        assertTrue(query.getParamNameValuePairs().containsValue(date + " 23:59:59"));
+    }
+}

+ 10 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/UserBillingMapper.java

@@ -18,6 +18,16 @@ import org.apache.ibatis.annotations.Select;
 public interface UserBillingMapper  extends BaseMapper<UserBilling>
 {
 
+    /** 分管账号按主账号及授权门店集合分页,空集合必须在 SQL 中限制为零行。 */
+    List<UserBilling> selectScopedMerchantBills(@Param("ownerId") Long ownerId, @Param("storeIds") List<Long> storeIds,
+            @Param("period") String period, @Param("date") String date, @Param("offset") Long offset, @Param("size") Long size);
+
+    BillDTO sumScopedMerchantBills(@Param("ownerId") Long ownerId, @Param("storeIds") List<Long> storeIds,
+            @Param("period") String period, @Param("date") String date);
+
+    ShBillingDto sumScopedMerchantGoods(@Param("ownerId") Long ownerId, @Param("storeIds") List<Long> storeIds,
+            @Param("period") String period, @Param("date") String date);
+
     //用户余额查询
     @Select("SELECT IFNULL(SUM(amount),0) FROM user_billing WHERE state=0 and type<>2 and user_id = #{userid}")  //查询语句
     List<UserBilling> getamsum(@Param("userid") Long userid);  //反回结果

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

@@ -1,5 +1,6 @@
 package com.ruoyi.system.service;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.domain.InfoUser;
@@ -111,6 +112,36 @@ public class MerchantStoreAccessService {
         return new MerchantAccessContext(loginUserId, owner.getUserId(), false, accessible);
     }
 
+    /** 业务门店范围包含夜市名下摊位;不改变 requireOwner 的主账号敏感操作规则。 */
+    public MerchantAccessContext resolveBusinessScope(Long loginUserId) {
+        InfoUser user = requireActiveUser(loginUserId);
+        if (!"3".equals(user.getUserType())) {
+            return resolve(loginUserId);
+        }
+        List<PosStore> stores = posStoreMapper.selectList(new LambdaQueryWrapper<PosStore>()
+                .eq(PosStore::getDelFlag, MerchantAccountConstants.NOT_DELETED)
+                .and(q -> q.eq(PosStore::getUserId, loginUserId).or().eq(PosStore::getNightMarketId, loginUserId)));
+        Set<Long> ids = new LinkedHashSet<>();
+        for (PosStore store : stores) {
+            ids.add(store.getId().longValue());
+        }
+        return new MerchantAccessContext(loginUserId, loginUserId, false, ids);
+    }
+
+    public void requireBusinessStoreAccess(Long loginUserId, Long storeId) {
+        if (storeId == null || !resolveBusinessScope(loginUserId).storeIds().contains(storeId)) {
+            throw denied("merchant.store.access.denied");
+        }
+    }
+
+    /** 修改必须先验证旧归属,且不允许通过管理接口转移记录到另一门店。 */
+    public void requireUnchangedBusinessStore(Long loginUserId, Long oldStoreId, Long newStoreId) {
+        requireBusinessStoreAccess(loginUserId, oldStoreId);
+        if (newStoreId != null && !Objects.equals(oldStoreId, newStoreId)) {
+            throw denied("merchant.store.access.denied");
+        }
+    }
+
     public void requireOwner(Long loginUserId) {
         if (!resolve(loginUserId).owner()) {
             throw denied("merchant.owner.required");

+ 1 - 1
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionActivityServiceImpl.java

@@ -395,7 +395,7 @@ public class PromotionActivityServiceImpl extends ServiceImpl<BaseMapper<Promoti
         {
             throw new ServiceException(MessageUtils.message("promotion.freeDelivery.forbiddenStore"));
         }
-        MerchantAccessContext context = storeAccessService.resolve(operatorUserId);
+        MerchantAccessContext context = storeAccessService.resolveBusinessScope(operatorUserId);
         if (!context.storeIds().contains(storeId))
         {
             throw new ServiceException(MessageUtils.message("promotion.freeDelivery.forbiddenStore"));

+ 35 - 0
ruoyi-system/src/main/resources/mapper/Billing/UserBillingMapper.xml

@@ -157,4 +157,39 @@
             #{id}
         </foreach>
     </delete>
+    <!-- 所有分管旧账单查询共用范围;不能省略空门店集合的条件。 -->
+    <sql id="scopedMerchantWhere">
+        b.user_id = #{ownerId} AND b.type = '0'
+        <choose>
+            <when test="storeIds != null and storeIds.size() > 0">
+                AND b.md_id IN
+                <foreach collection="storeIds" item="storeId" open="(" separator="," close=")">#{storeId}</foreach>
+            </when>
+            <otherwise>AND 1 = 0</otherwise>
+        </choose>
+        <choose>
+            <when test='period == "MONTH"'>AND DATE_FORMAT(b.cretim, '%Y-%m') = #{date}</when>
+            <when test='period == "WEEK"'>AND WEEK(b.cretim) = WEEK(#{date}) AND YEAR(b.cretim) = YEAR(#{date})</when>
+            <when test='period == "DAY"'>AND DATE(b.cretim) = DATE(#{date})</when>
+            <otherwise>AND 1 = 0</otherwise>
+        </choose>
+    </sql>
+
+    <select id="selectScopedMerchantBills" resultMap="UserBillingResult">
+        <include refid="selectUserBillingVo"/>
+        WHERE <include refid="scopedMerchantWhere"/>
+        ORDER BY b.id DESC LIMIT #{offset}, #{size}
+    </select>
+    <select id="sumScopedMerchantBills" resultType="com.ruoyi.system.domain.vo.BillDTO">
+        SELECT COUNT(*) AS sul, IFNULL(SUM(b.amount),0) AS shouru,
+               IFNULL(SUM(b.divvy),0) AS commission, IFNULL(SUM(b.tax),0) AS tax,
+               IFNULL(SUM(b.behalf_amount),0) AS behalfAmount
+        FROM user_billing b WHERE <include refid="scopedMerchantWhere"/> AND b.state = '0'
+    </select>
+    <select id="sumScopedMerchantGoods" resultType="com.ruoyi.system.domain.vo.ShBillingDto">
+        SELECT IFNULL(SUM(o.food_amount),0) AS foodAmount,
+               IFNULL(SUM(o.md_discount_amount),0) + IFNULL(SUM(o.md_sales_reduction),0) AS shYh
+        FROM user_billing b LEFT JOIN pos_order o ON b.dd_id = o.dd_id
+        WHERE <include refid="scopedMerchantWhere"/> AND b.state = '0'
+    </select>
 </mapper>

+ 38 - 0
ruoyi-system/src/test/java/com/ruoyi/system/mapper/ScopedMerchantBillingMapperTest.java

@@ -0,0 +1,38 @@
+package com.ruoyi.system.mapper;
+
+import com.ruoyi.system.domain.UserBilling;
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.Test;
+import java.util.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+class ScopedMerchantBillingMapperTest {
+    @Test void allQueriesBindOwnerStoresAndDatesAndFailClosedForEmptyStores() throws Exception {
+        Configuration config = new Configuration();
+        config.getTypeAliasRegistry().registerAlias("UserBilling", UserBilling.class);
+        String resource = "mapper/Billing/UserBillingMapper.xml";
+        try (var input = getClass().getClassLoader().getResourceAsStream(resource)) {
+            new XMLMapperBuilder(input, config, resource, config.getSqlFragments()).parse();
+        }
+        for (String method : List.of("selectScopedMerchantBills", "sumScopedMerchantBills", "sumScopedMerchantGoods")) {
+            for (String period : List.of("DAY", "WEEK", "MONTH")) {
+                Map<String, Object> args = new HashMap<>();
+                args.put("ownerId", 10L); args.put("storeIds", List.of(101L, 102L));
+                args.put("period", period); args.put("date", "2026-11-04' OR 1=1 --");
+                args.put("offset", 0L); args.put("size", 10L);
+                var stmt = config.getMappedStatement("com.ruoyi.system.mapper.UserBillingMapper." + method);
+                var bound = stmt.getBoundSql(args);
+                String sql = bound.getSql().replaceAll("\\s+", " ");
+                assertTrue(sql.contains("b.user_id = ?"));
+                assertTrue(sql.contains("b.md_id IN"));
+                assertFalse(sql.contains("OR 1=1"));
+                assertTrue(bound.getParameterMappings().stream().anyMatch(p -> p.getProperty().equals("date")));
+                args.put("storeIds", List.of());
+                assertTrue(stmt.getBoundSql(args).getSql().contains("AND 1 = 0"));
+                args.put("period", "invalid");
+                assertTrue(stmt.getBoundSql(args).getSql().contains("AND 1 = 0"));
+            }
+        }
+    }
+}

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

@@ -53,6 +53,27 @@ class MerchantStoreAccessServiceTest {
         messages.close();
     }
 
+    @Test
+    void businessScopePreservesNightMarketStoresWithoutGrantingMasterPrivileges() {
+        when(infoUserMapper.selectInfoUserByUserId(3L)).thenReturn(user(3L, "3", "0", null, null));
+        when(posStoreMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(store(10, 3L, "0")));
+        assertEquals(Set.of(10L), service.resolveBusinessScope(3L).storeIds());
+        service.requireBusinessStoreAccess(3L, 10L);
+        assertThrows(ServiceException.class, () -> service.requireBusinessStoreAccess(3L, 11L));
+        assertThrows(ServiceException.class, () -> service.requireOwner(3L));
+    }
+
+    @Test
+    void businessScopeRejectsDisabledUsersAndRecordTransferEvenBetweenOwnedStores() {
+        when(infoUserMapper.selectInfoUserByUserId(1L)).thenReturn(user(1L, "1", "0", null, null));
+        when(posStoreMapper.selectOwnedStoreIdsByUserId(1L)).thenReturn(List.of(10L, 11L));
+        service.requireUnchangedBusinessStore(1L, 10L, null);
+        assertThrows(ServiceException.class, () -> service.requireUnchangedBusinessStore(1L, 10L, 11L));
+        assertThrows(ServiceException.class, () -> service.requireUnchangedBusinessStore(1L, 99L, 10L));
+        when(infoUserMapper.selectInfoUserByUserId(2L)).thenReturn(user(2L, "3", "1", null, null));
+        assertThrows(ServiceException.class, () -> service.resolveBusinessScope(2L));
+    }
+
     @Test
     void stallOwnerResolvesOnlyItsBoundStallAndIsNotOwner() {
         InfoUser stallOwner = user(9L, "4", "0", null, null);

+ 3 - 3
ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PromotionActivityServiceImplFreeDeliveryTest.java

@@ -75,7 +75,7 @@ class PromotionActivityServiceImplFreeDeliveryTest
         messages = org.mockito.Mockito.mockStatic(MessageUtils.class);
         messages.when(() -> MessageUtils.message(any(String.class)))
                 .thenAnswer(invocation -> invocation.getArgument(0));
-        lenient().when(storeAccessService.resolve(OPERATOR_ID))
+        lenient().when(storeAccessService.resolveBusinessScope(OPERATOR_ID))
                 .thenReturn(new MerchantAccessContext(OPERATOR_ID, OPERATOR_ID, true, Set.of(STORE_ID)));
     }
 
@@ -158,7 +158,7 @@ class PromotionActivityServiceImplFreeDeliveryTest
     @Test
     void save_shouldRejectOtherStoreOwner()
     {
-        when(storeAccessService.resolve(OPERATOR_ID))
+        when(storeAccessService.resolveBusinessScope(OPERATOR_ID))
                 .thenReturn(new MerchantAccessContext(OPERATOR_ID, OPERATOR_ID, true, Set.of(77L)));
 
         ServiceException denied = assertThrows(ServiceException.class,
@@ -197,7 +197,7 @@ class PromotionActivityServiceImplFreeDeliveryTest
     @Test
     void detail_shouldRejectOtherStoreOwnerForFreeDelivery()
     {
-        when(storeAccessService.resolve(OPERATOR_ID))
+        when(storeAccessService.resolveBusinessScope(OPERATOR_ID))
                 .thenReturn(new MerchantAccessContext(OPERATOR_ID, OPERATOR_ID, true, Set.of(77L)));
         PromotionActivity stored = activeStored(0);
         when(promotionActivityMapper.selectById(stored.getId())).thenReturn(stored);

+ 10 - 0
specs/003-merchant-billing/plan.md

@@ -221,3 +221,13 @@ git commit -m "feat(billing): 商家端账单功能 — 列表查询、汇总、
 1. 独立 `AppDictController` 接收显式请求头及查询参数,复用 `ISysDictTypeService.selectDictDataByType`;响应投影为客户端选项。
 2. 新增参数错误国际化消息;使用现有 `@Anonymous + @Auth` 接入客户端会话认证,不修改平台后台字典接口。
 3. 定向验证响应契约、任意类型、空结果、停用项、参数与认证边界,并构建后端。无数据库迁移。
+
+## 2026-11-04 分管账号账单权限修复计划
+
+1. 用列表/汇总定向测试复现 type=5 被旧角色分支拒绝,覆盖授权门店选择、空授权、禁用/撤权及原角色回归。
+2. 两接口共用分管账单范围方法,读取实时上下文并同时约束主账号及门店;日期条件改为参数绑定,保证授权条件不可被筛选字符串绕开。
+3. 执行账单接口、共用门店权限及现有账单回归并打包,更新003与022规格,不操作数据库、不部署。
+
+### 旧账单范围补齐(2026-11-04)
+
+分管账号新增独立日/周/月查询分支;UserBillingMapper XML 共用主账号和门店集合条件,日期绑定参数、空集合显式 AND 1=0。保留旧响应结构和其他角色路径。验证包括授权多店/单店、越权、空授权、撤权、取消范围和 XML BoundSql。

+ 13 - 0
specs/003-merchant-billing/spec.md

@@ -10,6 +10,7 @@
 |----------|------|-------------|
 | 1 | 商家 | 自己名下所有门店的账单(user_id 查询,可选按 mdId 筛选) |
 | 4 | 摊位主/门店 | 仅自己关联门店的账单(md_id 过滤) |
+| 5 | 商家分管账号 | 所属主账号下当前授权门店的账单,列表与汇总范围一致 |
 
 ## 数据模型
 
@@ -158,3 +159,15 @@ mdId 过滤采用 `(#{mdId} IS NULL OR md_id = #{mdId})` 模式,mdId 不传时
 ## 2026-11-04 客户端共用字典查询
 
 新增 `GET /app/dict/data?dictType=sys_billing_type`,商家及用户端共用 `token` 请求头,使用现有客户端会话认证。用户明确确认允许已登录客户端查询任意字典类型,不增加类型白名单。只返回启用项,复用现有缓存及排序;返回 `dictLabel`、`dictValue`、`dictSort`、`isDefault`,不返回后台审计字段。缺失、空白或超过100字符的类型返回国际化业务错误;不存在或无启用项返回空数组。标签使用后台维护原文,不自动翻译或改写类型5的业务含义。本增量仅提供共用接口,不改商家页面或字典数据。
+
+## 2026-11-04 分管账号账单查询修复
+
+- `GET /Billing/billing/storeBillingList` 和 `/storeBillingSummary` 支持商家分管账号 `userType=5`,token 和请求/响应字段不变。
+- 每次请求复用 `MerchantStoreAccessService.resolve` 校验分管账号、主账号状态及当前门店授权;账单限定 `user_id=ownerUserId` 且 `md_id` 在授权门店内。传入 `mdId` 必须属于授权集合;未传时仅汇总/查询授权门店。
+- 无授权门店时列表为空、汇总为零,不能省略门店过滤而查出主账号全量账单;指定未授权门店则返回已有国际化权限错误。
+- 原商家/夜市主及摊位账号范围保持,用户和骑手继续拒绝。日期筛选使用绑定参数,避免筛选值改变权限条件。
+- 本次只开放授权门店账单查询,不开放提现、结算、收款配置或主账号全局财务;无数据库和前端变更。
+
+### 2026-11-04 旧日/周/月账单分管兼容
+
+`getzddaylist/getzdmeeklist/getzdmatlist` 对 userType=5 解析主账号及实时授权门店;不传 mdId 查询全部授权门店,指定未授权门店拒绝。明细、金额/商品汇总、取消订单列表及数量使用相同范围,空授权查询零行。原普通商家/夜市/摊主/骑手路径不变,无数据库迁移。

+ 11 - 0
specs/003-merchant-billing/tasks.md

@@ -129,3 +129,14 @@ description: "Task list for 商家前端账单功能 feature implementation"
 - [x] T018 运行字典与认证定向回归及JDK21构建,记录结果。
 
 验证:JDK21执行 `mvn -q -pl ruoyi-admin -am -Dtest=AppDictControllerTest,AuthAspectTest -Dsurefire.failIfNoSpecifiedTests=false package`,退出码0,11项测试通过(字典6、认证5),失败/错误/跳过均0;差异检查通过。日志 `ruoyi-admin/target/app-dict-query.log`。未修改客户端页面,未进行线上字典联调、全量回归或部署;提交状态以 Git 记录为准。
+
+## 2026-11-04 分管账号账单查询权限
+
+- [x] T019 列表与汇总支持分管账号实时授权门店,并限制主账号归属;未授权/空授权安全处理。
+- [x] T020 日期参数绑定与旧角色、门店权限回归,补齐003/022文档并构建后端。
+
+验证记录:修复前18项定向测试中12项失败,复现分管账号被拒绝;修复后 JDK21 执行 `mvn -q -pl ruoyi-admin -am -Dtest=StoreBillingSubaccountAccessTest,MerchantStoreAccessServiceTest,UserBillingFlashDeliveryTest,OrderBillingNoDeductionTest -Dsurefire.failIfNoSpecifiedTests=false package`,40项全部通过,模块打包成功。`git diff --check`通过。日志 `ruoyi-admin/target/store-billing-access-red.log` / `store-billing-access-green.log`。无数据库变更,未执行线上账号/App联调、部署或全量回归,提交状态以Git为准。
+
+- [x] T021 补齐分管旧日/周/月账单及取消统计的实时授权范围,执行控制器与 SQL 回归,保留骑手原账单行为。验证结果与022 T015共用。
+
+T021验证:022 T015所列JDK21定向回归144项全部通过,模块package成功,差异检查通过。分管旧账单控制器12项、SQL范围测试1项,以及既有商家/骑手账单回归均包含在内。无数据库变更,未部署;提交与推送状态以 Git 记录为准。

+ 18 - 0
specs/022-merchant-store-subaccounts/contracts/api.md

@@ -139,3 +139,21 @@ void sendStoreNotification(
 ```
 
 返回值不影响原订单事务。每个选中账号先生成自己的 `push_message`,再尝试外部推送;单个账号失败只记录日志。店铺无法解析时只向 `ownerUserId` 兜底。
+
+## 2026-11-04 业务接口授权补齐
+
+以下管理请求均须带 `token` 请求头,参数主体和响应字段沿用原接口。所有门店必须在登录账号实时授权范围内,账号或主账号停用、权限被撤销立即拒绝。
+
+| 接口 | 调整 |
+|---|---|
+| `POST /hours/addHours`、`POST /hours/setHours`、`GET /hours/delHours` | 补充 token;按实际营业时间记录的 mdId 验证权限 |
+| `/system/shPromotionCoupon`、`/system/shPromotionActivity` 的列表、详情及增删改/结束/下架 | 所有类型统一校验 storeId,修改先验证旧记录,不能转移门店 |
+| `/system/shQuanyi`、`/system/shSalesPromotion` 的管理接口 | 旧字段 shId 在这里指门店 ID,不是商家 ID;创建优惠券时 merchantId 服务端根据门店确定 |
+| `/system/shPosReview/list`、详情、修改、删除 | token 认证,按授权门店查询/操作;空授权返回空页 |
+| `GET /index/getshindex` | token 必填,旧 id 可不传;传入也不用于决定商家身份。分管按授权门店统计,ketixian 为 null,不代表可提现余额为零 |
+| `/Billing/billing/storeBillingList`、`storeBillingSummary` | 分管可查询授权门店账单;不传 mdId 为全部授权门店,指定未授权 mdId 拒绝 |
+| `/Billing/billing/getzddaylist`、`getzdmeeklist`、`getzdmatlist` | 分管使用主账号账单归属和授权门店集合;isCancel 及 cancelCount 使用相同范围 |
+
+全量维护 `GET /hours/setStoreHours` 需要平台 `system:hours:edit`;`GET /system/shQuanyi/setMerchatId` 需要平台 `system:quanyi:edit`,商家 token 不可调用。
+
+消费者查询营业时间/预约时间、`getorderuzt` 查询可用优惠及 `getMyquanyi` 查询个人权益沿用原用途。批量删除先检查全部记录,任一越权不会删除其他项。无新字段、无数据库迁移;提现和收款设置的既有权限不在本次变更范围内。

+ 11 - 0
specs/022-merchant-store-subaccounts/plan.md

@@ -204,3 +204,14 @@ ruoyi-admin/src/test/java/com/ruoyi/app/order/DeliveryOrderNotificationServiceTe
 ## 2026-11-04 `/stall/list` 授权修复
 
 在 `StallController.getStallList` 增加类型 5 分支,复用 `MerchantStoreAccessService.getAccessibleStoreIds` 解析实时权限;空集合直接返回空列表,否则通过 `id IN` 查询。新增定向测试覆盖授权集合、空授权、账号不可用及既有角色兼容,并联跑集中权限服务和现有摊位测试。无数据库结构变更。
+
+## 2026-11-04 商家业务入口权限补漏
+
+沿用当前分支。集中权限服务增加业务范围解析以兼容普通商家、分管账号、摊主及夜市主;主账号敏感操作继续使用原 requireOwner,不扩大提现、结算、收款配置权限。
+
+1. 营业时间、新旧优惠券和促销、评论:登录后校验实时授权门店,修改时先读取旧记录,禁止更换记录归属,批量删除先全部校验。两个全量维护入口仅平台对应权限可调用;消费者公开营业时间和可用活动查询保持原用途。
+2. 旧日/周/月账单为分管账号新增主账号加授权门店集合查询,列表、汇总和取消订单使用同一范围;空集合显式零行、日期使用绑定参数。
+3. 首页从认证上下文获取身份,兼容旧 id 参数但不以它决定查询对象;分管/摊位只返回门店统计,不返回主账号可提现金额。评论空授权直接空结果。
+4. 用登录契约、越权 ID、伪造门店、空授权、批量删除原子校验和 SQL 参数绑定回归验证,并运行 JDK 21 模块构建。无数据库结构变更,不部署、不自动提交。
+
+验收:合法授权账号可用,未授权/停用账号被拒绝,普通商家和骑手原账单路径不变;相关接口响应主体保持兼容。

+ 11 - 0
specs/022-merchant-store-subaccounts/quickstart.md

@@ -58,3 +58,14 @@ npm run build:prod
 - 平台端:`npm run build:prod` 成功;存在既有 `promotion/index.vue~`、`pointcontrol/pointTier.vue~` 解析警告及包体积提示,未阻断构建。
 - 数据库迁移未执行,`updatesql/sql.md` 由开发者统一手动执行。
 - 尚未执行依赖真实数据库、Redis、App 会话、CID 和 iOS 云函数的手工验收;上方 10 条手工路径保持为上线前验收清单。
+
+## 2026-11-04 商家旧业务入口回归
+
+1. 主账号拥有门店 A/B/C,分管只授权 A/B。用分管 token 调用营业时间、优惠券、促销、评论:A/B 可操作,C 的列表/详情/增删改应拒绝。
+2. 修改 C 的记录时在请求体改成 A,仍应拒绝;批量删除 [A记录,C记录] 应全部不执行。
+3. 清空分管授权:首页及评论/账单应为零或空,不能退化成主账号全部数据。恢复授权后立即生效;停用分管或主账号后立即拒绝。
+4. 旧日/周/月账单不传 mdId 时只汇总 A/B,指定 A 只统计 A;isCancel 和 cancelCount 也不包含 C。确认普通商家及骑手账单沿用原行为。
+5. 首页传入其他商家 id 不得改变统计对象;分管不返回主账号 ketixian 余额。现有客户端需在新增受保护入口发送 token。
+6. 匿名或商家 token 调用全量维护接口不可执行;平台只有具备对应权限才可执行。无需为验收实际触发全量维护。
+
+上述真实账号与客户端联调由发布后执行,单元测试和本地构建结果见 tasks.md。

+ 12 - 2
specs/022-merchant-store-subaccounts/spec.md

@@ -43,7 +43,7 @@
 3. **假如** 账号甲负责 A,**当** 其操作 A 的订单、商品、营业状态、店铺基础资料或退款,**那么** 系统允许符合原业务规则的操作。
 4. **假如** 账号甲未负责 C,**当** 其通过伪造店铺 ID、订单 ID 或商品 ID 尝试读取或修改 C 的数据,**那么** 后端拒绝操作且不泄露 C 的业务数据。
 5. **假如** 当前登录者是分管账号,**当** 其调用新增店铺接口,**那么** 系统拒绝创建;前端是否隐藏入口不影响后端校验结果。
-6. **假如** 当前登录者是分管账号,**当** 其尝试删除店铺、修改收款配置、提现、结算或查看跨店财务汇总,**那么** 系统拒绝操作。
+6. **假如** 当前登录者是分管账号,**当** 其尝试删除店铺、修改收款配置、提现、结算或查看授权范围外的财务汇总,**那么** 系统拒绝操作。
 
 ---
 
@@ -130,7 +130,7 @@
 - **FR-011**:所有店铺范围的列表查询必须在后端按当前账号的可访问店铺集合过滤,不能只依赖前端隐藏数据。
 - **FR-012**:所有店铺范围的详情和修改操作必须从目标数据解析实际门店归属,并校验当前账号权限,不能仅信任客户端提交的门店 ID。
 - **FR-013**:分管账号必须可以在授权店铺范围内查看和操作订单、商品、营业状态、店铺基础资料及退款,且仍需满足各业务原有状态流转和校验规则。
-- **FR-014**:分管账号必须禁止新增或删除店铺、管理分管账号、修改收款配置、提现、结算及查看跨店财务汇总。
+- **FR-014**:分管账号必须禁止新增或删除店铺、管理分管账号、修改收款配置、提现、结算及查看授权范围外的财务汇总。
 - **FR-015**:新增店铺接口必须在后端明确校验当前登录者为主账号;不能将前端入口隐藏视为权限控制。
 - **FR-016**:账号停用或店铺授权移除后,新的查询、操作和通知路由必须立即使用最新权限,不得要求重新登录后才生效。
 - **FR-017**:店铺业务通知必须以实际店铺为路由依据;订单通知优先使用订单保存的门店归属。
@@ -208,7 +208,17 @@
 - 餐桌码的夜市及摊位账号沿用现有权限规则;顾客扫码 `/table-qrcode/scan` 行为不变。
 - 支付方式及银行卡遵循后续 `031-pay-method-config` 的主账号共享设置规格,不据此恢复旧的子账号禁用规则。
 
+## 2026-11-04 授权门店账单查询补充
+
+按用户本次要求,分管账号可调用 `storeBillingList/storeBillingSummary` 查看所属主账号下当前授权门店的账单及汇总。未指定门店仅查询授权集合,指定越权门店拒绝,无授权集合返回空列表/零汇总;不授予主账号全局财务、提现或结算权限。细节及验证见 `specs/003-merchant-billing` 本次增量。
+
 ## 2026-11-04 `/stall/list` 分管账号授权补漏
 
 - 商家分管账号(`userType=5`)调用 `/stall/list` 时,只返回实时授权且仍有效的门店;不得按分管账号 ID 查询门店归属,也不得回退为主账号全部门店。
 - 无授权门店返回空列表;账号或主账号不可用时沿用集中权限服务拒绝访问。普通商家、夜市和摊位主的原有查询规则保持不变。
+
+### 2026-11-04 商家业务接口授权补齐
+
+营业时间、新旧优惠券/促销和评论管理必须登录,并按当前授权门店校验列表、详情及增删改。修改先校验数据库旧记录归属,不能借请求体中的新门店绕过校验;批量删除任何一项越权均不得删除。全量营业时间修复、优惠券商家归属修复仅平台相应权限可操作。
+
+分管账号的首页、日/周/月账单与取消统计按主账号及授权门店集合计算;空授权不得返回全量。首页保留旧字段,分管和摊位账号的 ketixian 返回 null,不代表主账号余额为零。旧 id 参数仅兼容,不作为首页查询身份。消费者查询营业时间、可用优惠活动不转成商家管理权限。无数据库迁移。

+ 15 - 0
specs/022-merchant-store-subaccounts/tasks.md

@@ -395,3 +395,18 @@ T005..T007 --------------------------> T012
 - [x] 运行 JDK 21 定向回归与模块构建,检查差异与换行。
 
 验证记录:为避免同工作区另一项 Maven 构建干扰,在独立源码快照中使用本机 JDK 21.0.12。修复前新测试 5 项中 3 项按预期失败;修复后执行 `mvn -q -pl ruoyi-admin -am -Dtest=StallListAccessTest,StallControllerTest,MerchantStoreAccessServiceTest -Dsurefire.failIfNoSpecifiedTests=false package`,退出码 0,20 项测试全部通过。日志保存至 `ruoyi-admin/target/stall-list-red.log`、`ruoyi-admin/target/stall-list-green.log`。差异与换行检查通过;未执行全量回归、真实数据库/API 联调或部署;提交与推送状态以 Git 记录为准。
+
+## T015 商家业务入口权限补漏(2026-11-04)
+
+- [x] 营业时间、新旧券/促销、评论管理补齐登录和记录门店校验;平台维护入口恢复权限限制。
+- [x] 首页统计及旧日/周/月账单、取消订单按分管授权范围查询。
+- [x] 覆盖越权、合法授权、空授权、禁用及既有角色行为,执行 JDK 21 测试和构建。
+- [x] 更新规格、接入说明及验证记录,检查差异和换行。
+
+已验证(未部署,提交与推送状态以 Git 记录为准):新增登录契约测试先复现匿名写接口遗漏;摊位收入回归先复现同店骑手账单未排除,修复后通过。JDK 21 执行
+
+`mvn -q -pl ruoyi-admin -am -Dtest=MerchantBusiness*Test,MerchantDashboardScopeTest,*BillingSubaccount*Test,ScopedMerchantBillingMapperTest,MerchantStoreAccessServiceTest,ShPromotionActivityFreeDeliveryTest,PromotionActivityServiceImplFreeDeliveryTest,MerchantLegacyEntryAccessTest,StallListAccessTest,UserBillingFlashDeliveryTest,OrderBillingNoDeductionTest,AuthAspectTest -Dsurefire.failIfNoSpecifiedTests=false package`
+
+退出码0,14个测试类共144项,失败/错误/跳过均0,ruoyi-admin及依赖模块打包成功;差异检查通过,保留原工作区换行。日志:`ruoyi-admin/target/merchant-business-red.log`、`merchant-dashboard-stall-red.log`、`merchant-business-final.log`。独立静态复核未发现新增阻断项。未执行全量回归、真实账号/App/PC联调、数据库迁移或线上部署。
+
+执行决定:旧首页 id 参数继续接受但不参与身份判定;活动更新沿用数据库原类型和门店;分管旧账单使用独立路径保留其他角色行为;摊位首页按门店并限定商家账单 user_type=1,避免把骑手运费算入商家收入。前端应发送 token,分管/摊位 ketixian=null 不可解读为主账号余额。