Răsfoiți Sursa

feat: 摊位资料审核与营业准入校验

qmj 1 săptămână în urmă
părinte
comite
f3cc315ba1
47 a modificat fișierele cu 1763 adăugiri și 2 ștergeri
  1. 5 1
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreEnrichService.java
  2. 5 0
      ruoyi-admin/src/main/java/com/ruoyi/app/nightmarket/NightMarketStoreController.java
  3. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java
  4. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  5. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
  6. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/app/stall/StallController.java
  7. 9 0
      ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/QualificationReviewRequest.java
  8. 17 0
      ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/QualificationSaveRequest.java
  9. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/QualificationUploadRequest.java
  10. 18 0
      ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/QualificationView.java
  11. 38 0
      ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/StallQualificationAdminController.java
  12. 22 0
      ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/StallQualificationBindingAdvice.java
  13. 57 0
      ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/StallQualificationController.java
  14. 72 0
      ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/StallQualificationFileService.java
  15. 187 0
      ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/StallQualificationService.java
  16. 12 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  17. 12 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  18. 12 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  19. 12 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  20. 3 1
      ruoyi-admin/src/main/resources/logback.xml
  21. 4 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderShOprateControllerTest.java
  22. 83 0
      ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationControllerTest.java
  23. 43 0
      ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationFileServiceTest.java
  24. 28 0
      ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationGateServiceTest.java
  25. 84 0
      ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationMapperTest.java
  26. 53 0
      ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationOrderGateTest.java
  27. 151 0
      ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationServiceTest.java
  28. 59 0
      ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationStatFilterTest.java
  29. 93 0
      ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationStoreGateTest.java
  30. 9 0
      ruoyi-framework/src/main/java/com/ruoyi/framework/config/DruidConfig.java
  31. 18 0
      ruoyi-framework/src/main/java/com/ruoyi/framework/config/StallQualificationStatFilter.java
  32. 13 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/StallQualification.java
  33. 37 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/StallQualificationApplication.java
  34. 20 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/StallQualificationSummary.java
  35. 25 0
      ruoyi-system/src/main/java/com/ruoyi/system/handler/StallImageListTypeHandler.java
  36. 24 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/StallQualificationMapper.java
  37. 21 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/StallQualificationGateService.java
  38. 47 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosStoreServiceImpl.java
  39. 4 0
      ruoyi-system/src/main/resources/mapper/chanting/PosStoreMapper.xml
  40. 87 0
      ruoyi-system/src/main/resources/mapper/chanting/StallQualificationMapper.xml
  41. 22 0
      specs/034-stall-qualification/contracts/api.md
  42. 29 0
      specs/034-stall-qualification/data-model.md
  43. 65 0
      specs/034-stall-qualification/plan.md
  44. 50 0
      specs/034-stall-qualification/quickstart.md
  45. 75 0
      specs/034-stall-qualification/spec.md
  46. 36 0
      specs/034-stall-qualification/tasks.md
  47. 79 0
      updatesql/sql.md

+ 5 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreEnrichService.java

@@ -15,6 +15,7 @@ import com.ruoyi.system.mapper.PosFoodMapper;
 import com.ruoyi.system.service.IOperatingHoursService;
 import com.ruoyi.system.service.IPosOrderService;
 import com.ruoyi.system.service.IPosReviewService;
+import com.ruoyi.system.service.StallQualificationGateService;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
@@ -36,6 +37,8 @@ public class PosStoreEnrichService {
     private IOperatingHoursService operatingHoursService;
     @Autowired
     private PosFoodMapper posFoodMapper;
+    @Autowired
+    private StallQualificationGateService stallQualificationGateService;
 
     /**
      * 为门店列表设置评分、月售、营业状态。
@@ -98,7 +101,8 @@ public class PosStoreEnrichService {
                     .filter(x -> store.getId() != null && store.getId().equals(x.getMdId().intValue()))
                     .collect(Collectors.toList());
             dayang = OperatingUtil.storeIsOperatingOfDay(mdHours);
-            if (!dayang) {
+            // 未审核摊位显示休息中;新订单接口仍独立校验,不能信任列表状态。
+            if (!dayang || !stallQualificationGateService.canOperate(store)) {
                 store.setState(1L);
             }
         }

+ 5 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/nightmarket/NightMarketStoreController.java

@@ -17,6 +17,7 @@ import com.ruoyi.system.service.IPosCollectService;
 import com.ruoyi.system.service.IPosOrderService;
 import com.ruoyi.system.service.IPosReviewService;
 import com.ruoyi.system.service.IPosStoreService;
+import com.ruoyi.system.service.StallQualificationGateService;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -40,6 +41,8 @@ public class NightMarketStoreController extends BaseController {
 
     @Autowired
     private IPosStoreService posStoreService;
+    @Autowired
+    private StallQualificationGateService stallQualificationGateService;
 
     @Autowired
     private IPosCollectService posCollectService;
@@ -164,6 +167,8 @@ public class NightMarketStoreController extends BaseController {
         List<PosFood> foods = posFoodMapper.selectFoodsByStoreId(storeId, langCode);
 
         JSONObject result = new JSONObject();
+        // 详情明确返回不可营业状态,与列表和下单资格保持一致。
+        result.put("state", stallQualificationGateService.canOperate(store) ? store.getState() : 1L);
         result.put("id", store.getId());
         result.put("posName", store.getPosName());
         result.put("image", store.getImage());

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java

@@ -103,6 +103,8 @@ public class PosOrderController extends BaseController {
     private IInfoUserService infoUserService;
     @Autowired
     private IPosStoreService posStoreService;
+    @Autowired
+    private StallQualificationGateService stallQualificationGateService;
     /** 支付方式闸门(031):商家建单与用户下单共用同一判定入口。 */
     @Autowired
     private com.ruoyi.system.service.PaymentMethodGateService paymentMethodGateService;
@@ -1077,6 +1079,8 @@ public class PosOrderController extends BaseController {
         wrapper.eq("md_id", orderDTO.getMdId());
         List<OperatingHours> hourslist = operatingHoursService.list(wrapper);
         PosStore store = posStoreService.getById(orderDTO.getMdId());
+        // 旧路由仍可调用,最小门禁防止从这里绕过摊位材料审核。
+        stallQualificationGateService.requireApproved(store);
 
         JSONObject usdizhi = new JSONObject();
         if (orderDTO.getType() != null && orderDTO.getType().equals(0L)) {

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -64,6 +64,8 @@ public class PosOrderShOprateController extends BaseController {
     private OrderLogHelper orderLogHelper;
     @Autowired
     private IPosStoreService posStoreService;
+    @Autowired
+    private StallQualificationGateService stallQualificationGateService;
     @Autowired  //经营时间
     private IOperatingHoursService operatingHoursService;
     @Autowired
@@ -225,6 +227,8 @@ public class PosOrderShOprateController extends BaseController {
                 throw new ServiceException(MessageUtils.message("no.mendian.not.exist"));
             }
             PosStore store = storeOptional.get();
+            // 商家代客/堂食创建也不能绕过摊位审核,旧订单出餐、配送不受影响。
+            stallQualificationGateService.requireApproved(store);
             posOrder.setLongitude(store.getLongitude());
             posOrder.setLatitude(store.getLatitude());
             // 029 商家自配送:外送单按门店时段判定并固化快照(即时=下单时刻;预约按 delryTime 时间段开始时刻)

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -66,6 +66,8 @@ public class UserOrderController extends BaseController {
     @Autowired
     private IPosStoreService posStoreService;
     @Autowired
+    private StallQualificationGateService stallQualificationGateService;
+    @Autowired
     private IInfoAddressService infoAddressService;
     @Autowired
     private IOperatingHoursService operatingHoursService;
@@ -515,6 +517,8 @@ public class UserOrderController extends BaseController {
                 .filter(x -> Long.valueOf(x.getId()).equals(item.getMdId()))
                 .findFirst()
                 .orElseThrow(() -> new ServiceException(MessageUtils.message("no.mendian.not.exist")));
+        // 034:存量和新增摊位均须通过材料审核;仅拦截新单,不影响已有订单履约。
+        stallQualificationGateService.requireApproved(store);
         if (storeHourslist.isEmpty()) {
             throw new ServiceException(store.getPosName()+MessageUtils.message("no.mendian.not.set.business.hours"));
         }

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/stall/StallController.java

@@ -250,6 +250,10 @@ public class StallController extends BaseController {
         queryWrapper.eq("night_market_id", nightMarketId);
         queryWrapper.eq("is_stall", 1);
         queryWrapper.eq("off_shelf", "0");
+        // 扫码点餐入口只展示有通过材料的摊位,不沿用旧账号审核状态放行。
+        queryWrapper.exists("SELECT 1 FROM stall_qualification q JOIN stall_qualification_application a "
+                + "ON a.id=q.approved_application_id AND a.store_id=q.store_id "
+                + "WHERE q.store_id=pos_store.id AND a.status=2");
         queryWrapper.select("id", "pos_name", "image", "logo", "brief_introduction");
         List<PosStore> list = posStoreService.list(queryWrapper);
         return success(list);

+ 9 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/QualificationReviewRequest.java

@@ -0,0 +1,9 @@
+package com.ruoyi.app.stall.qualification;
+import lombok.Data;
+
+@Data
+public class QualificationReviewRequest {
+    private Long version;
+    private Boolean approved;
+    private String rejectReason;
+}

+ 17 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/QualificationSaveRequest.java

@@ -0,0 +1,17 @@
+package com.ruoyi.app.stall.qualification;
+
+import lombok.Data;
+import java.util.List;
+
+/** 客户端只能填写材料和并发版本;归属与审核结果由服务端写入。 */
+@Data
+public class QualificationSaveRequest {
+    private Long version;
+    private String responsibleName;
+    private String contactPhone;
+    private String identityNumber;
+    private List<String> identityImageUrls;
+    private List<String> stallImageUrls;
+    private String foodRegistrationNumber;
+    private List<String> foodRegistrationImageUrls;
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/QualificationUploadRequest.java

@@ -0,0 +1,7 @@
+package com.ruoyi.app.stall.qualification;
+import lombok.Data;
+import org.springframework.web.multipart.MultipartFile;
+
+/** multipart DTO;不使用隐式业务参数绑定或 Bean Validation。 */
+@Data
+public class QualificationUploadRequest { private MultipartFile file; }

+ 18 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/QualificationView.java

@@ -0,0 +1,18 @@
+package com.ruoyi.app.stall.qualification;
+
+import com.ruoyi.system.domain.StallQualificationApplication;
+import com.ruoyi.system.domain.StallQualificationSummary;
+import lombok.Data;
+import java.util.List;
+
+@Data
+public class QualificationView {
+    private Long storeId;
+    private String stallName;
+    private String stallAddress;
+    private Long nightMarketId;
+    private Long version;
+    private StallQualificationApplication currentApplication;
+    private StallQualificationApplication approvedApplication;
+    private List<StallQualificationSummary> history;
+}

+ 38 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/StallQualificationAdminController.java

@@ -0,0 +1,38 @@
+package com.ruoyi.app.stall.qualification;
+
+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.utils.SecurityUtils;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+/** 平台审核按独立权限授权;材料请求/响应不写入 @Log,避免泄露证件。 */
+@RestController
+@RequestMapping("/system/stallQualification")
+public class StallQualificationAdminController extends BaseController {
+    private final StallQualificationService service;
+    private final StallQualificationFileService files;
+    public StallQualificationAdminController(StallQualificationService service, StallQualificationFileService files) {
+        this.service = service; this.files = files;
+    }
+    @PreAuthorize("@ss.hasPermi('stall:qualification:list')") @GetMapping
+    public TableDataInfo list(@RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize,
+                             @RequestParam(required = false) Integer status, @RequestParam(required = false) Long storeId) {
+        TableDataInfo result = new TableDataInfo();
+        result.setRows(service.adminList(pageNum, pageSize, status, storeId));
+        result.setTotal(service.adminCount(status, storeId)); result.setCode(200); return result;
+    }
+    @PreAuthorize("@ss.hasPermi('stall:qualification:query')") @GetMapping("/applications/{id}")
+    public AjaxResult detail(@PathVariable Long id) { return success(service.adminApplication(id)); }
+    @PreAuthorize("@ss.hasPermi('stall:qualification:review')") @PostMapping("/applications/{id}/review")
+    public AjaxResult review(@PathVariable Long id, @RequestBody QualificationReviewRequest request) {
+        service.review(SecurityUtils.getUserId(), id, request); return success();
+    }
+    @PreAuthorize("@ss.hasPermi('stall:qualification:query')") @GetMapping("/files/{storeId}/{fileName}")
+    public ResponseEntity<ByteArrayResource> file(@PathVariable Long storeId, @PathVariable String fileName) {
+        return StallQualificationController.privateImage(files.read(storeId, storeId + "/" + fileName), fileName);
+    }
+}

+ 22 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/StallQualificationBindingAdvice.java

@@ -0,0 +1,22 @@
+package com.ruoyi.app.stall.qualification;
+
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.MessageUtils;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.validation.BindException;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+/** 仅材料接口使用安全绑定错误:Jackson/绑定异常会携带原始证件字段,不交给全局异常日志。 */
+@Order(Ordered.HIGHEST_PRECEDENCE)
+@RestControllerAdvice(assignableTypes = {StallQualificationController.class, StallQualificationAdminController.class})
+public class StallQualificationBindingAdvice {
+    @ExceptionHandler({HttpMessageNotReadableException.class, BindException.class, MethodArgumentTypeMismatchException.class})
+    public AjaxResult invalidInput(Exception exception) {
+        // 不记录 exception 或原请求,响应也不回显收到的号码、附件、材料内容。
+        return AjaxResult.error(MessageUtils.message("stall.qualification.input.invalid"));
+    }
+}

+ 57 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/StallQualificationController.java

@@ -0,0 +1,57 @@
+package com.ruoyi.app.stall.qualification;
+
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.http.*;
+import org.springframework.web.bind.annotation.*;
+
+/** 摊主提交和夜市进度入口;无外部可写归属、状态或审核人字段。 */
+@RestController
+@RequestMapping("/stall/qualification")
+public class StallQualificationController extends BaseController {
+    private final StallQualificationService service;
+    private final StallQualificationFileService files;
+    public StallQualificationController(StallQualificationService service, StallQualificationFileService files) {
+        this.service = service; this.files = files;
+    }
+    @Anonymous @Auth @GetMapping
+    public AjaxResult current(@RequestHeader String token) { return success(service.ownerView(userId(token))); }
+    @Anonymous @Auth @PostMapping("/draft")
+    public AjaxResult draft(@RequestHeader String token, @RequestBody QualificationSaveRequest request) {
+        return success(service.save(userId(token), request, false));
+    }
+    @Anonymous @Auth @PostMapping("/submit")
+    public AjaxResult submit(@RequestHeader String token, @RequestBody QualificationSaveRequest request) {
+        return success(service.save(userId(token), request, true));
+    }
+    @Anonymous @Auth @GetMapping("/applications/{id}")
+    public AjaxResult application(@RequestHeader String token, @PathVariable Long id) {
+        return success(service.applicationForOwner(userId(token), id));
+    }
+    @Anonymous @Auth @GetMapping("/market")
+    public AjaxResult market(@RequestHeader String token) { return success(service.marketProgress(userId(token))); }
+    @Anonymous @Auth @PostMapping(value = "/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+    public AjaxResult upload(@RequestHeader String token, @ModelAttribute QualificationUploadRequest request) {
+        String reference = files.upload(service.ownerStoreId(userId(token)), request.getFile());
+        // success(String) 是消息重载;附件引用必须放到 data,否则前端拿不到可保存的引用。
+        return success((Object) reference);
+    }
+    @Anonymous @Auth @GetMapping("/files/{storeId}/{fileName}")
+    public ResponseEntity<ByteArrayResource> file(@RequestHeader String token, @PathVariable Long storeId, @PathVariable String fileName) {
+        if (!service.ownerStoreId(userId(token)).equals(storeId)) { throw new ServiceException(MessageUtils.message("merchant.store.access.denied")); }
+        return privateImage(files.read(storeId, storeId + "/" + fileName), fileName);
+    }
+    static ResponseEntity<ByteArrayResource> privateImage(ByteArrayResource resource, String fileName) {
+        return ResponseEntity.ok().cacheControl(CacheControl.noStore())
+                .header("X-Content-Type-Options", "nosniff")
+                .contentType(fileName.endsWith(".png") ? MediaType.IMAGE_PNG : MediaType.IMAGE_JPEG)
+                .body(resource);
+    }
+    private Long userId(String token) { return Long.valueOf(new JwtUtil().getusid(token)); }
+}

+ 72 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/StallQualificationFileService.java

@@ -0,0 +1,72 @@
+package com.ruoyi.app.stall.qualification;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+import javax.imageio.ImageIO;
+import javax.imageio.ImageReader;
+import javax.imageio.stream.ImageInputStream;
+import java.nio.file.*;
+import java.io.ByteArrayInputStream;
+import java.util.Iterator;
+import java.util.UUID;
+
+/** 身份附件不进入公开 profile 映射;调用方必须先完成身份/权限校验。 */
+@Service
+public class StallQualificationFileService {
+    private static final int MAX_BYTES = 5 * 1024 * 1024;
+    private final Path root;
+    public StallQualificationFileService(@Value("${ruoyi.profile}") String profile) {
+        Path publicRoot = Paths.get(profile).toAbsolutePath().normalize();
+        root = publicRoot.resolveSibling("stall-qualification");
+        if (root.startsWith(publicRoot)) { throw new IllegalArgumentException("Private qualification directory overlaps profile"); }
+    }
+    public String upload(Long storeId, MultipartFile file) {
+        if (storeId == null || storeId <= 0 || file == null || file.isEmpty() || file.getSize() > MAX_BYTES) { throw invalid(); }
+        try {
+            byte[] bytes = file.getBytes();
+            String extension;
+            try (ImageInputStream input = ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))) {
+                Iterator<ImageReader> readers = ImageIO.getImageReaders(input);
+                if (!readers.hasNext()) { throw invalid(); }
+                ImageReader reader = readers.next();
+                try {
+                    reader.setInput(input);
+                    extension = reader.getFormatName().toLowerCase(java.util.Locale.ROOT);
+                    if ("jpeg".equals(extension)) { extension = "jpg"; }
+                    // 检查真实格式和尺寸,不相信客户端 MIME/文件名;限制解码面积防止压缩图炸弹。
+                    if (!("jpg".equals(extension) || "png".equals(extension))
+                            || (long) reader.getWidth(0) * reader.getHeight(0) > 20_000_000L) { throw invalid(); }
+                    reader.read(0);
+                } finally { reader.dispose(); }
+            }
+            String reference = storeId + "/" + UUID.randomUUID() + "." + extension;
+            Path directory = root.resolve(String.valueOf(storeId));
+            Files.createDirectories(directory);
+            if (!directory.toRealPath().startsWith(root.toRealPath())) { throw invalid(); }
+            Files.write(root.resolve(reference), bytes, StandardOpenOption.CREATE_NEW);
+            return reference;
+        } catch (ServiceException exception) { throw exception; }
+        catch (Exception exception) { throw new ServiceException(MessageUtils.message("stall.qualification.file.failed")); }
+    }
+    public void requireReference(Long storeId, String reference) { checkedPath(storeId, reference); }
+    public ByteArrayResource read(Long storeId, String reference) {
+        Path path = checkedPath(storeId, reference);
+        try { return new ByteArrayResource(Files.readAllBytes(path)); }
+        catch (Exception exception) { throw invalid(); }
+    }
+    private Path checkedPath(Long storeId, String reference) {
+        if (storeId == null || reference == null || !reference.matches(storeId + "/[0-9a-f-]{36}\\.(jpg|png)")) { throw invalid(); }
+        Path path = root.resolve(reference).normalize();
+        try {
+            if (!path.startsWith(root) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)
+                    || !path.toRealPath().startsWith(root.toRealPath()) || Files.size(path) > MAX_BYTES) { throw invalid(); }
+            return path;
+        } catch (ServiceException exception) { throw exception; }
+        catch (Exception exception) { throw invalid(); }
+    }
+    private ServiceException invalid() { return new ServiceException(MessageUtils.message("stall.qualification.file.invalid")); }
+}

+ 187 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/StallQualificationService.java

@@ -0,0 +1,187 @@
+package com.ruoyi.app.stall.qualification;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.*;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.mapper.StallQualificationMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import java.util.Date;
+import java.util.List;
+import java.util.Objects;
+import static com.ruoyi.system.domain.StallQualificationApplication.*;
+
+/** 摊位级材料和审核事务;不复用账号 auditStatus,不影响已产生订单的履约。 */
+@Service
+public class StallQualificationService {
+    private final StallQualificationMapper mapper;
+    private final InfoUserMapper users;
+    private final PosStoreMapper stores;
+    private final StallQualificationFileService files;
+    public StallQualificationService(StallQualificationMapper mapper, InfoUserMapper users,
+                                     PosStoreMapper stores, StallQualificationFileService files) {
+        this.mapper = mapper; this.users = users; this.stores = stores; this.files = files;
+    }
+
+    public Long ownerStoreId(Long userId) {
+        InfoUser user = requireUser(userId, "4");
+        PosStore store = user.getStoreId() == null ? null : stores.selectPosStoreById(user.getStoreId());
+        requireStall(store);
+        return user.getStoreId();
+    }
+    private InfoUser requireUser(Long userId, String type) {
+        InfoUser user = userId == null ? null : users.selectInfoUserByUserId(userId);
+        if (user == null || !type.equals(user.getUserType()) || !"0".equals(user.getStatus()) || !"0".equals(user.getDelFlag())) {
+            throw error("merchant.store.access.denied");
+        }
+        return user;
+    }
+    private void requireStall(PosStore store) {
+        if (store == null || !Integer.valueOf(1).equals(store.getIsStall()) || !"0".equals(store.getDelFlag())) {
+            throw error("merchant.store.access.denied");
+        }
+    }
+
+    /** 读操作不创建草稿,未填写的存量摊位明确返回 version=0、空申请。 */
+    @Transactional(readOnly = true)
+    public QualificationView ownerView(Long userId) {
+        Long storeId = ownerStoreId(userId);
+        return view(stores.selectPosStoreById(storeId), mapper.selectByStoreId(storeId));
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public QualificationView save(Long userId, QualificationSaveRequest request, boolean submit) {
+        Long storeId = ownerStoreId(userId);
+        if (request == null || request.getVersion() == null || request.getVersion() < 0) { throw error("stall.qualification.version.required"); }
+        PosStore store = stores.selectPosStoreById(storeId);
+        // 唯一键初始化 + 主体行锁:多个摊主账号、首次并发提交也只会产生一个待审核版本。
+        mapper.ensureMaster(storeId);
+        StallQualification qualification = mapper.lockByStoreId(storeId);
+        if (qualification == null || !Objects.equals(qualification.getVersion(), request.getVersion())) { throw conflict(); }
+        StallQualificationApplication previous = qualification.getCurrentApplicationId() == null ? null
+                : mapper.selectApplication(qualification.getCurrentApplicationId());
+        if (previous != null && Integer.valueOf(PENDING).equals(previous.getStatus())) { throw error("stall.qualification.pending"); }
+        StallQualificationApplication application = new StallQualificationApplication();
+        application.setQualificationId(qualification.getId()); application.setStoreId(storeId);
+        application.setResponsibleName(text(request.getResponsibleName(), 100));
+        application.setContactPhone(text(request.getContactPhone(), 32));
+        application.setIdentityNumber(text(request.getIdentityNumber(), 64));
+        application.setFoodRegistrationNumber(text(request.getFoodRegistrationNumber(), 64));
+        application.setIdentityImageUrls(images(storeId, request.getIdentityImageUrls()));
+        application.setStallImageUrls(images(storeId, request.getStallImageUrls()));
+        application.setFoodRegistrationImageUrls(images(storeId, request.getFoodRegistrationImageUrls()));
+        application.setStallNameSnapshot(store.getPosName()); application.setStallAddressSnapshot(store.getAddress());
+        application.setNightMarketIdSnapshot(store.getNightMarketId());
+        application.setStatus(submit ? PENDING : DRAFT);
+        application.setVersion(0L); application.setCreateTime(new Date()); application.setUpdateTime(new Date());
+        if (submit) {
+            requireMaterials(application);
+            application.setSubmittedBy(userId); application.setSubmittedAt(new Date());
+        }
+        if (previous != null && Integer.valueOf(DRAFT).equals(previous.getStatus())) {
+            application.setId(previous.getId()); application.setApplicationVersion(previous.getApplicationVersion());
+            application.setVersion(previous.getVersion()); application.setCreateTime(previous.getCreateTime());
+            if (mapper.updateDraft(application) != 1) { throw conflict(); }
+            application.setVersion(application.getVersion() + 1);
+        } else {
+            // 已提交/已审核材料保持不可变;通过后修改或驳回重提,都建立下一个材料版本。
+            application.setApplicationVersion(previous == null ? 1 : previous.getApplicationVersion() + 1);
+            if (mapper.insertApplication(application) != 1 || application.getId() == null) { throw conflict(); }
+        }
+        qualification.setCurrentApplicationId(application.getId());
+        bumpMaster(qualification);
+        QualificationView view = view(store, qualification);
+        view.setCurrentApplication(application);
+        return view;
+    }
+
+    public StallQualificationApplication applicationForOwner(Long userId, Long id) {
+        Long storeId = ownerStoreId(userId);
+        StallQualificationApplication application = mapper.selectApplication(id);
+        if (application == null || !storeId.equals(application.getStoreId())) { throw error("merchant.store.access.denied"); }
+        return application;
+    }
+
+    /** 夜市主仅看到进度,材料和身份字段从查询源头就不返回。 */
+    public List<StallQualificationSummary> marketProgress(Long userId) {
+        requireUser(userId, "3"); return mapper.selectMarketProgress(userId);
+    }
+
+    public StallQualificationApplication adminApplication(Long id) {
+        StallQualificationApplication application = id == null ? null : mapper.selectApplication(id);
+        if (application == null) { throw error("stall.qualification.not.found"); }
+        return application;
+    }
+    public List<StallQualificationSummary> adminList(int pageNum, int pageSize, Integer status, Long storeId) {
+        checkPage(pageNum, pageSize, status);
+        return mapper.selectApplications(status, storeId, (pageNum - 1) * pageSize, pageSize);
+    }
+    public long adminCount(Integer status, Long storeId) { return mapper.countApplications(status, storeId); }
+    private void checkPage(int pageNum, int pageSize, Integer status) {
+        if (pageNum < 1 || pageSize < 1 || pageSize > 100 || (long) (pageNum - 1) * pageSize > Integer.MAX_VALUE
+                || (status != null && (status < DRAFT || status > REJECTED))) { throw error("stall.qualification.input.invalid"); }
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void review(Long reviewerId, Long id, QualificationReviewRequest request) {
+        if (request == null || request.getVersion() == null || request.getApproved() == null) { throw error("stall.qualification.input.invalid"); }
+        String reason = text(request.getRejectReason(), 500);
+        if (!request.getApproved() && reason.isEmpty()) { throw error("stall.qualification.reason.required"); }
+        StallQualificationApplication initial = adminApplication(id);
+        // 和保存/提交使用同一主体锁;锁后重读防止读到另一个管理员操作前的旧状态。
+        StallQualification qualification = mapper.lockByStoreId(initial.getStoreId());
+        StallQualificationApplication application = adminApplication(id);
+        if (qualification == null || !Objects.equals(id, qualification.getCurrentApplicationId())
+                || !Integer.valueOf(PENDING).equals(application.getStatus())
+                || !Objects.equals(application.getVersion(), request.getVersion())) { throw conflict(); }
+        requireStall(stores.selectPosStoreById(application.getStoreId()));
+        application.setStatus(request.getApproved() ? APPROVED : REJECTED);
+        application.setReviewedBy(reviewerId); application.setReviewedAt(new Date());
+        application.setRejectReason(request.getApproved() ? null : reason);
+        if (mapper.reviewApplication(application) != 1) { throw conflict(); }
+        // 新申请驳回不能抹掉旧有效版本;审核通过只切换有效指针,不自动替商家开店。
+        if (request.getApproved()) { qualification.setApprovedApplicationId(id); }
+        bumpMaster(qualification);
+    }
+
+    private QualificationView view(PosStore store, StallQualification qualification) {
+        QualificationView view = new QualificationView();
+        Long storeId = store.getId().longValue();
+        view.setStoreId(storeId); view.setStallName(store.getPosName()); view.setStallAddress(store.getAddress());
+        view.setNightMarketId(store.getNightMarketId()); view.setVersion(qualification == null ? 0L : qualification.getVersion());
+        if (qualification != null) {
+            if (qualification.getCurrentApplicationId() != null) { view.setCurrentApplication(mapper.selectApplication(qualification.getCurrentApplicationId())); }
+            if (qualification.getApprovedApplicationId() != null) { view.setApprovedApplication(mapper.selectApplication(qualification.getApprovedApplicationId())); }
+        }
+        view.setHistory(mapper.selectHistory(storeId)); return view;
+    }
+    private void bumpMaster(StallQualification qualification) {
+        if (mapper.updateMaster(qualification) != 1) { throw conflict(); }
+        qualification.setVersion(qualification.getVersion() + 1);
+    }
+    private List<String> images(Long storeId, List<String> references) {
+        if (references == null) { return List.of(); }
+        if (references.size() > 5) { throw error("stall.qualification.input.invalid"); }
+        for (String reference : references) { files.requireReference(storeId, reference); }
+        return List.copyOf(references);
+    }
+    private void requireMaterials(StallQualificationApplication application) {
+        if (application.getResponsibleName().isEmpty() || application.getContactPhone().isEmpty()
+                || application.getIdentityNumber().isEmpty() || application.getFoodRegistrationNumber().isEmpty()
+                || application.getIdentityImageUrls().isEmpty() || application.getStallImageUrls().isEmpty()
+                || application.getFoodRegistrationImageUrls().isEmpty()
+                || application.getStallNameSnapshot() == null || application.getStallNameSnapshot().isBlank()
+                || application.getStallAddressSnapshot() == null || application.getStallAddressSnapshot().isBlank()) {
+            throw error("stall.qualification.materials.required");
+        }
+    }
+    private String text(String value, int max) {
+        String normalized = value == null ? "" : value.trim();
+        if (normalized.length() > max) { throw error("stall.qualification.input.invalid"); }
+        return normalized;
+    }
+    private ServiceException conflict() { return error("stall.qualification.conflict"); }
+    private ServiceException error(String key) { return new ServiceException(MessageUtils.message(key)); }
+}

+ 12 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -425,3 +425,15 @@ no.pay.switch.offline.restricted=Tiền mặt / thanh toán khi nhận hàng ch
 no.pay.switch.failed=Đổi phương thức thanh toán thất bại, vui lòng thử lại sau
 flash.delivery.billing.save.failed=Không thể ghi nhận thu nhập giao hàng nhanh, vui lòng thử lại
 flash.delivery.billing.income=Thu nhập giao hàng nhanh (thanh toán trực tiếp cho tài xế)
+
+# 034 Stall qualification: common material rules and private images
+stall.qualification.version.required=Vui lòng tải lại trước khi lưu: thiếu phiên bản hồ sơ
+stall.qualification.conflict=Hồ sơ đã thay đổi hoặc được duyệt. Vui lòng tải lại.
+stall.qualification.pending=Hồ sơ đang chờ duyệt, không thể sửa hoặc nộp lại.
+stall.qualification.not.found=Không tìm thấy hồ sơ xét duyệt
+stall.qualification.input.invalid=Thông tin hồ sơ không hợp lệ
+stall.qualification.reason.required=Phải nhập lý do từ chối
+stall.qualification.materials.required=Vui lòng bổ sung liên hệ, giấy tờ tùy thân, ảnh quầy và đăng ký thực phẩm; tên và địa chỉ quầy cũng phải có.
+stall.qualification.approval.required=Hồ sơ quầy chưa được duyệt. Không được mở bán hoặc tạo đơn mới.
+stall.qualification.file.failed=Không thể xử lý tệp hồ sơ. Vui lòng thử lại.
+stall.qualification.file.invalid=Ảnh hồ sơ không hợp lệ, vượt giới hạn hoặc thuộc quầy khác

+ 12 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -411,3 +411,15 @@ no.pay.switch.offline.restricted=Cash / cash-on-delivery only for pickup or dine
 no.pay.switch.failed=Failed to change payment method, please try again later
 flash.delivery.billing.save.failed=Failed to record flash delivery income, please try again
 flash.delivery.billing.income=Flash delivery income (paid directly to rider)
+
+# 034 Stall qualification: common material rules and private images
+stall.qualification.version.required=Refresh before saving: material version is missing
+stall.qualification.conflict=Materials have changed or been reviewed. Refresh and try again.
+stall.qualification.pending=Materials are under review and cannot be edited or resubmitted.
+stall.qualification.not.found=Qualification application not found
+stall.qualification.input.invalid=Invalid qualification parameters
+stall.qualification.reason.required=A rejection reason is required
+stall.qualification.materials.required=Complete contact details, identity, stall photos and food registration; stall name and address are also required.
+stall.qualification.approval.required=Stall materials are not approved. Opening and new orders are not allowed.
+stall.qualification.file.failed=Failed to process qualification file. Please try again.
+stall.qualification.file.invalid=Invalid qualification image, limit exceeded or image belongs to another stall

+ 12 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -413,3 +413,15 @@ no.pay.switch.offline.restricted=仅自取/堂食订单可更换为现金或货
 no.pay.switch.failed=更换支付方式失败,请稍后重试
 flash.delivery.billing.save.failed=闪送骑手账单生成失败,请重试
 flash.delivery.billing.income=闪送收入(款项直付骑手)
+
+# 034 Stall qualification: common material rules and private images
+stall.qualification.version.required=请刷新后再保存,缺少资料版本
+stall.qualification.conflict=资料已变化或已审核,请刷新后重试
+stall.qualification.pending=资料正在审核,暂不可修改或重复提交
+stall.qualification.not.found=审核申请不存在
+stall.qualification.input.invalid=审核资料参数不正确
+stall.qualification.reason.required=驳回必须填写原因
+stall.qualification.materials.required=请补齐负责人姓名电话、证件、摊位照片及食品业者登录资料,摊位名称地址也不能为空
+stall.qualification.approval.required=摊位材料尚未审核通过,暂不能营业或创建新订单
+stall.qualification.file.failed=审核材料文件处理失败,请稍后重试
+stall.qualification.file.invalid=审核图片无效、超出限制或不属于当前摊位

+ 12 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -412,3 +412,15 @@ no.pay.switch.offline.restricted=僅自取/堂食訂單可更換為現金或貨
 no.pay.switch.failed=更換支付方式失敗,請稍後重試
 flash.delivery.billing.save.failed=閃送騎手帳單產生失敗,請重試
 flash.delivery.billing.income=閃送收入(款項直付騎手)
+
+# 034 Stall qualification: common material rules and private images
+stall.qualification.version.required=請重新整理後再儲存,缺少資料版本
+stall.qualification.conflict=資料已變更或已審核,請重新整理後重試
+stall.qualification.pending=資料正在審核,暫不可修改或重複提交
+stall.qualification.not.found=審核申請不存在
+stall.qualification.input.invalid=審核資料參數不正確
+stall.qualification.reason.required=駁回必須填寫原因
+stall.qualification.materials.required=請補齊負責人姓名電話、證件、攤位照片及食品業者登錄資料,攤位名稱地址也不能空白
+stall.qualification.approval.required=攤位材料尚未審核通過,暫不能營業或建立新訂單
+stall.qualification.file.failed=審核材料檔案處理失敗,請稍後重試
+stall.qualification.file.invalid=審核圖片無效、超過限制或不屬於目前攤位

+ 3 - 1
ruoyi-admin/src/main/resources/logback.xml

@@ -73,6 +73,8 @@
 	
 	<!-- 系统模块日志级别控制  -->
 	<logger name="com.ruoyi" level="info" />
+    <!-- 034:MyBatis DEBUG/TRACE 会输出 SQL 参数和证件结果,材料 Mapper 禁止开启明细日志。 -->
+    <logger name="com.ruoyi.system.mapper.StallQualificationMapper" level="warn" />
 	<!-- Spring日志级别控制  -->
 	<logger name="org.springframework" level="warn" />
 
@@ -90,4 +92,4 @@
     <logger name="sys-user" level="info">
         <appender-ref ref="sys-user"/>
     </logger>
-</configuration> 
+</configuration>

+ 4 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderShOprateControllerTest.java

@@ -113,6 +113,10 @@ class PosOrderShOprateControllerTest {
     @Test
     void createOrderPersistsMerchantSourceBeforeReturning() {
         PosOrderShOprateController controller = new PosOrderShOprateController();
+        // 新增材料门禁为真实依赖;普通商家仍由原审核机制管理。
+        ReflectionTestUtils.setField(controller, "stallQualificationGateService",
+                new com.ruoyi.system.service.StallQualificationGateService(
+                        mock(com.ruoyi.system.mapper.StallQualificationMapper.class)));
         IInfoUserService users = mock(IInfoUserService.class);
         IPosOrderService orders = mock(IPosOrderService.class);
         IPosStoreService stores = mock(IPosStoreService.class);

+ 83 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationControllerTest.java

@@ -0,0 +1,83 @@
+package com.ruoyi.app.stall.qualification;
+
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.annotation.Log;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.security.access.prepost.PreAuthorize;
+import java.lang.reflect.Method;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.http.MediaType;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class StallQualificationControllerTest {
+    @Test void everyMerchantEndpointRequiresAuthAndPlatformEndpointsRequireDedicatedPermission() {
+        for (Method method : StallQualificationController.class.getDeclaredMethods()) {
+            if (!java.lang.reflect.Modifier.isPublic(method.getModifiers())) continue;
+            assertNotNull(method.getAnnotation(Auth.class), method.getName());
+            assertNotNull(method.getAnnotation(Anonymous.class), method.getName());
+            assertNull(method.getAnnotation(Log.class));
+        }
+        for (Method method : StallQualificationAdminController.class.getDeclaredMethods()) {
+            if (!java.lang.reflect.Modifier.isPublic(method.getModifiers())) continue;
+            PreAuthorize permission = method.getAnnotation(PreAuthorize.class);
+            assertNotNull(permission, method.getName());
+            assertTrue(permission.value().contains("stall:qualification:"));
+            assertNull(method.getAnnotation(Log.class));
+        }
+    }
+
+    @Test void privateImageEndpointRejectsOtherStallsAndNeverCachesIdentityDocuments() {
+        StallQualificationService service = mock(StallQualificationService.class);
+        StallQualificationFileService files = mock(StallQualificationFileService.class);
+        when(service.ownerStoreId(20L)).thenReturn(8L);
+        StallQualificationController controller = new StallQualificationController(service, files);
+        String token = JwtUtil.setToken("20", "owner");
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            assertThrows(ServiceException.class, () -> controller.file(token, 9L, "image.jpg"));
+        }
+        verifyNoInteractions(files);
+        when(files.read(8L, "8/image.jpg")).thenReturn(new ByteArrayResource(new byte[] {1}));
+        var result = controller.file(token, 8L, "image.jpg");
+        assertEquals("no-store", result.getHeaders().getCacheControl());
+        assertEquals("nosniff", result.getHeaders().getFirst("X-Content-Type-Options"));
+        assertEquals("image/jpeg", result.getHeaders().getContentType().toString());
+    }
+
+    @Test void uploadReturnsPrivateReferenceAsDataNotSuccessMessage() {
+        StallQualificationService service = mock(StallQualificationService.class);
+        StallQualificationFileService files = mock(StallQualificationFileService.class);
+        when(service.ownerStoreId(20L)).thenReturn(8L);
+        var image = new org.springframework.mock.web.MockMultipartFile("file", new byte[] {1});
+        String reference = "8/00000000-0000-0000-0000-000000000000.jpg";
+        when(files.upload(8L, image)).thenReturn(reference);
+        QualificationUploadRequest request = new QualificationUploadRequest(); request.setFile(image);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("no.action.success")).thenReturn("ok");
+            var response = new StallQualificationController(service, files).upload(JwtUtil.setToken("20", "owner"), request);
+            assertEquals(reference, response.get("data"));
+        }
+    }
+
+    @Test void malformedMaterialsAreNotReturnedOrLoggedThroughGlobalExceptionHandler() throws Exception {
+        StallQualificationService service = mock(StallQualificationService.class);
+        var mvc = MockMvcBuilders.standaloneSetup(new StallQualificationController(service, mock(StallQualificationFileService.class)))
+                .setControllerAdvice(new StallQualificationBindingAdvice()).build();
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("stall.qualification.input.invalid")).thenReturn("invalidMaterials");
+            mvc.perform(post("/stall/qualification/draft").header("token", "test")
+                    .contentType(MediaType.APPLICATION_JSON)
+                    .content("{\"responsibleName\":\"private name\",\"identityImageUrls\":\"8/private-identity-photo.jpg\"}"))
+                    .andExpect(jsonPath("$.msg").value("invalidMaterials"));
+        }
+        verifyNoInteractions(service);
+    }
+}

+ 43 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationFileServiceTest.java

@@ -0,0 +1,43 @@
+package com.ruoyi.app.stall.qualification;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.MockedStatic;
+import org.springframework.mock.web.MockMultipartFile;
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.nio.file.Path;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.mockStatic;
+
+class StallQualificationFileServiceTest {
+    @TempDir Path directory;
+
+    @Test void privateFileRoundTripsOutsidePublicProfile() throws Exception {
+        StallQualificationFileService service = new StallQualificationFileService(directory.resolve("public").toString());
+        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+        ImageIO.write(new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB), "png", bytes);
+        String reference = service.upload(8L, new MockMultipartFile("file", "photo.png", "image/png", bytes.toByteArray()));
+        assertTrue(reference.startsWith("8/")); assertTrue(reference.endsWith(".png"));
+        assertArrayEquals(bytes.toByteArray(), service.read(8L, reference).getByteArray());
+        assertFalse(java.nio.file.Files.exists(directory.resolve("public").resolve(reference)));
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            assertThrows(ServiceException.class, () -> service.requireReference(9L, reference));
+            assertThrows(ServiceException.class, () -> service.read(8L, "8/../password.txt"));
+            assertThrows(ServiceException.class, () -> service.read(8L, "8/not-found.png"));
+        }
+    }
+
+    @Test void fakeImagesAndOversizeUploadsAreRejected() {
+        StallQualificationFileService service = new StallQualificationFileService(directory.resolve("public").toString());
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            assertThrows(ServiceException.class, () -> service.upload(8L,
+                    new MockMultipartFile("file", "fake.png", "image/png", "not an image".getBytes())));
+            assertThrows(ServiceException.class, () -> service.upload(8L,
+                    new MockMultipartFile("file", "big.png", "image/png", new byte[5 * 1024 * 1024 + 1])));
+        }
+    }
+}

+ 28 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationGateServiceTest.java

@@ -0,0 +1,28 @@
+package com.ruoyi.app.stall.qualification;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.mapper.StallQualificationMapper;
+import com.ruoyi.system.service.StallQualificationGateService;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class StallQualificationGateServiceTest {
+    @Test void bothLegacyAndNewStallsWithoutApprovalAreDeniedButOrdinaryMerchantsAreUnaffected() {
+        StallQualificationMapper mapper = mock(StallQualificationMapper.class);
+        StallQualificationGateService service = new StallQualificationGateService(mapper);
+        PosStore stall = new PosStore(); stall.setId(8); stall.setIsStall(1);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            assertFalse(service.canOperate(stall));
+            assertThrows(ServiceException.class, () -> service.requireApproved(stall));
+        }
+        when(mapper.hasApproved(8L)).thenReturn(true);
+        assertTrue(service.canOperate(stall)); assertDoesNotThrow(() -> service.requireApproved(stall));
+        PosStore ordinary = new PosStore(); ordinary.setId(9); ordinary.setIsStall(0);
+        assertTrue(service.canOperate(ordinary)); assertDoesNotThrow(() -> service.requireApproved(ordinary));
+        verify(mapper, never()).hasApproved(9L);
+    }
+}

+ 84 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationMapperTest.java

@@ -0,0 +1,84 @@
+package com.ruoyi.app.stall.qualification;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.ruoyi.system.domain.StallQualificationApplication;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.handler.StallImageListTypeHandler;
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.apache.ibatis.mapping.BoundSql;
+import org.apache.ibatis.type.JdbcType;
+import org.junit.jupiter.api.Test;
+import java.io.InputStream;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.util.List;
+import java.util.Map;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/** 真实解析 Mapper 和 TypeHandler;不使用线上数据库,也不声称替代 MySQL 并发验证。 */
+class StallQualificationMapperTest {
+    private MybatisConfiguration configuration() throws Exception {
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        String resource = "mapper/chanting/StallQualificationMapper.xml";
+        try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
+            assertNotNull(input);
+            new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
+        }
+        return configuration;
+    }
+    private BoundSql sql(MybatisConfiguration configuration, String id, Object value) {
+        return configuration.getMappedStatement("com.ruoyi.system.mapper.StallQualificationMapper." + id).getBoundSql(value);
+    }
+
+    @Test void xmlBindsPrivateListsAndReviewNeverUpdatesSubmittedMaterials() throws Exception {
+        MybatisConfiguration configuration = configuration();
+        assertTrue(configuration.isMapUnderscoreToCamelCase());
+        StallQualificationApplication application = new StallQualificationApplication();
+        application.setId(30L); application.setStoreId(8L); application.setVersion(0L);
+        application.setIdentityImageUrls(List.of("8/id.jpg"));
+        BoundSql insert = sql(configuration, "insertApplication", application);
+        assertTrue(insert.getParameterMappings().stream().anyMatch(parameter ->
+                "identityImageUrls".equals(parameter.getProperty()) && parameter.getTypeHandler() instanceof StallImageListTypeHandler));
+        String review = sql(configuration, "reviewApplication", application).getSql();
+        assertTrue(review.contains("status = 1 AND version = ?"));
+        assertFalse(review.contains("identity_number")); assertFalse(review.contains("identity_image_urls"));
+        assertTrue(sql(configuration, "lockByStoreId", Map.of("storeId", 8L)).getSql().contains("FOR UPDATE"));
+        assertTrue(sql(configuration, "updateDraft", application).getSql().contains("status = 0 AND version = ?"));
+    }
+
+    @Test void listAndMarketSummaryQueriesDoNotSelectPrivateMaterialFields() throws Exception {
+        MybatisConfiguration configuration = configuration();
+        String list = sql(configuration, "selectApplications", Map.of("status", 1, "storeId", 8L, "offset", 0, "limit", 10)).getSql();
+        String market = sql(configuration, "selectMarketProgress", Map.of("marketId", 10L)).getSql();
+        for (String query : List.of(list, market)) {
+            assertFalse(query.contains("identity_number")); assertFalse(query.contains("responsible_name"));
+            assertFalse(query.contains("contact_phone")); assertFalse(query.contains("image_urls"));
+        }
+        assertTrue(list.contains("LIMIT ? OFFSET ?")); assertTrue(market.contains("s.user_id = ?"));
+    }
+
+    @Test void imageListHandlerUsesJsonRoundTripAndRejectsMalformedStorage() throws Exception {
+        StallImageListTypeHandler handler = new StallImageListTypeHandler();
+        PreparedStatement statement = mock(PreparedStatement.class);
+        handler.setNonNullParameter(statement, 1, List.of("8/a.jpg", "8/b.png"), JdbcType.VARCHAR);
+        verify(statement).setString(1, "[\"8/a.jpg\",\"8/b.png\"]");
+        ResultSet result = mock(ResultSet.class);
+        when(result.getString("images")).thenReturn("[\"8/a.jpg\",\"8/b.png\"]");
+        assertEquals(List.of("8/a.jpg", "8/b.png"), handler.getNullableResult(result, "images"));
+        when(result.getString("images")).thenReturn("not json");
+        assertThrows(java.sql.SQLException.class, () -> handler.getNullableResult(result, "images"));
+    }
+
+    @Test void customStallInsertActuallyPersistsClosedState() throws Exception {
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        configuration.getTypeAliasRegistry().registerAlias("PosStore", PosStore.class);
+        String resource = "mapper/chanting/PosStoreMapper.xml";
+        try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
+            new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
+        }
+        PosStore store = new PosStore(); store.setIsStall(1); store.setState(1L);
+        BoundSql insert = configuration.getMappedStatement("com.ruoyi.system.mapper.PosStoreMapper.insertPosStore").getBoundSql(store);
+        assertTrue(insert.getParameterMappings().stream().anyMatch(parameter -> "state".equals(parameter.getProperty())));
+    }
+}

+ 53 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationOrderGateTest.java

@@ -0,0 +1,53 @@
+package com.ruoyi.app.stall.qualification;
+
+import com.ruoyi.app.order.UserOrderController;
+import com.ruoyi.app.order.PosOrderShOprateController;
+import com.ruoyi.app.order.dto.OrderCreatItem;
+import com.ruoyi.app.order.dto.OrderCreateInput;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.service.IPosStoreService;
+import com.ruoyi.system.service.IOperatingHoursService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.StallQualificationGateService;
+import com.ruoyi.common.exception.ServiceException;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+import java.util.List;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class StallQualificationOrderGateTest {
+    @Test void userNewOrderChecksQualificationBeforeOpenHours() {
+        PosStore store = new PosStore(); store.setId(8); store.setIsStall(1);
+        OrderCreatItem item = new OrderCreatItem(); item.setMdId(8L);
+        StallQualificationGateService gate = mock(StallQualificationGateService.class);
+        doThrow(new ServiceException("notApproved")).when(gate).requireApproved(store);
+        UserOrderController controller = new UserOrderController();
+        ReflectionTestUtils.setField(controller, "stallQualificationGateService", gate);
+        assertThrows(ServiceException.class, () -> ReflectionTestUtils.invokeMethod(controller, "checkStoreOpen", List.of(store), List.of(), item));
+        verify(gate).requireApproved(store);
+    }
+
+    @Test void merchantNewOrderChecksQualificationBeforePersistingChild() {
+        PosStore store = new PosStore(); store.setId(8); store.setIsStall(1);
+        IPosStoreService stores = mock(IPosStoreService.class);
+        when(stores.list(any(LambdaQueryWrapper.class))).thenReturn(List.of(store));
+        IOperatingHoursService hours = mock(IOperatingHoursService.class);
+        when(hours.list(any(LambdaQueryWrapper.class))).thenReturn(List.of());
+        IPosOrderService orders = mock(IPosOrderService.class);
+        StallQualificationGateService gate = mock(StallQualificationGateService.class);
+        doThrow(new ServiceException("notApproved")).when(gate).requireApproved(store);
+        PosOrderShOprateController controller = new PosOrderShOprateController();
+        ReflectionTestUtils.setField(controller, "posStoreService", stores);
+        ReflectionTestUtils.setField(controller, "operatingHoursService", hours);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "stallQualificationGateService", gate);
+        OrderCreateInput input = new OrderCreateInput(); input.setDdId("test");
+        OrderCreatItem item = new OrderCreatItem(); item.setMdId(8L); input.setItems(List.of(item));
+        assertThrows(ServiceException.class, () -> ReflectionTestUtils.invokeMethod(controller, "createOrderChild", input, 3L, 3L, null));
+        verify(gate).requireApproved(store);
+        verifyNoInteractions(orders);
+    }
+}

+ 151 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationServiceTest.java

@@ -0,0 +1,151 @@
+package com.ruoyi.app.stall.qualification;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.domain.StallQualification;
+import com.ruoyi.system.domain.StallQualificationApplication;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.mapper.StallQualificationMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import java.util.List;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/** 行为断言覆盖跨摊位访问、不可变材料、重复审核和旧有效版本保护。 */
+class StallQualificationServiceTest {
+    private final StallQualificationMapper mapper = mock(StallQualificationMapper.class);
+    private final InfoUserMapper users = mock(InfoUserMapper.class);
+    private final PosStoreMapper stores = mock(PosStoreMapper.class);
+    private final StallQualificationFileService files = mock(StallQualificationFileService.class);
+    private final StallQualificationService service = new StallQualificationService(mapper, users, stores, files);
+    private StallQualification qualification;
+
+    @BeforeEach
+    void setup() {
+        InfoUser user = new InfoUser();
+        user.setUserType("4"); user.setStoreId(8L); user.setStatus("0"); user.setDelFlag("0");
+        when(users.selectInfoUserByUserId(20L)).thenReturn(user);
+        PosStore store = new PosStore();
+        store.setId(8); store.setIsStall(1); store.setDelFlag("0");
+        store.setUserId(10L); store.setNightMarketId(10L); store.setPosName("stall"); store.setAddress("address");
+        when(stores.selectPosStoreById(8L)).thenReturn(store);
+        qualification = new StallQualification();
+        qualification.setId(1L); qualification.setStoreId(8L); qualification.setVersion(0L);
+        when(mapper.lockByStoreId(8L)).thenReturn(qualification);
+        when(mapper.selectByStoreId(8L)).thenReturn(qualification);
+        when(mapper.updateMaster(any())).thenReturn(1);
+        when(mapper.insertApplication(any())).thenAnswer(invocation -> {
+            invocation.<StallQualificationApplication>getArgument(0).setId(30L); return 1;
+        });
+        when(mapper.selectHistory(8L)).thenReturn(List.of());
+    }
+
+    private QualificationSaveRequest complete() {
+        QualificationSaveRequest request = new QualificationSaveRequest();
+        request.setVersion(0L); request.setResponsibleName(" owner "); request.setContactPhone("0912345678");
+        request.setIdentityNumber("A123456789"); request.setFoodRegistrationNumber("A-123456789-00000-0");
+        request.setIdentityImageUrls(List.of("8/id.jpg")); request.setStallImageUrls(List.of("8/stall.jpg"));
+        request.setFoodRegistrationImageUrls(List.of("8/food.jpg")); return request;
+    }
+
+    @Test void emptyDraftCanBeSavedButCannotBeSubmitted() {
+        QualificationSaveRequest empty = new QualificationSaveRequest(); empty.setVersion(0L);
+        QualificationView view = service.save(20L, empty, false);
+        assertEquals(0, view.getCurrentApplication().getStatus());
+        assertEquals(1L, view.getVersion());
+        qualification.setVersion(0L);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            assertThrows(ServiceException.class, () -> service.save(20L, empty, true));
+        }
+    }
+
+    @Test void submitFreezesServerOwnedSnapshotAndNormalizesName() {
+        QualificationView view = service.save(20L, complete(), true);
+        StallQualificationApplication application = view.getCurrentApplication();
+        assertEquals(1, application.getStatus()); assertEquals("owner", application.getResponsibleName());
+        assertEquals(8L, application.getStoreId()); assertEquals(10L, application.getNightMarketIdSnapshot());
+        assertEquals("stall", application.getStallNameSnapshot()); assertEquals(20L, application.getSubmittedBy());
+        assertNotNull(application.getSubmittedAt());
+    }
+
+    @Test void staleVersionCannotOverwriteAnotherOwnersDraft() {
+        qualification.setVersion(1L);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            assertThrows(ServiceException.class, () -> service.save(20L, complete(), false));
+        }
+        verify(mapper, never()).insertApplication(any());
+    }
+
+    @Test void pendingApplicationCannotBeEditedOrSubmittedAgain() {
+        StallQualificationApplication pending = application(1); qualification.setCurrentApplicationId(30L);
+        when(mapper.selectApplication(30L)).thenReturn(pending);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            assertThrows(ServiceException.class, () -> service.save(20L, complete(), false));
+            assertThrows(ServiceException.class, () -> service.save(20L, complete(), true));
+        }
+        verify(mapper, never()).updateDraft(any());
+    }
+
+    @Test void rejectedResubmissionCreatesNewVersionWithoutTouchingOldApproval() {
+        qualification.setCurrentApplicationId(30L); qualification.setApprovedApplicationId(11L);
+        StallQualificationApplication rejected = application(3); rejected.setApplicationVersion(2);
+        when(mapper.selectApplication(30L)).thenReturn(rejected);
+        QualificationView view = service.save(20L, complete(), true);
+        assertEquals(3, view.getCurrentApplication().getApplicationVersion());
+        assertEquals(11L, qualification.getApprovedApplicationId());
+        verify(mapper, never()).updateDraft(any());
+    }
+
+    @Test void anotherStallsMaterialsAndDisabledAccountsAreDenied() {
+        when(mapper.selectApplication(30L)).thenReturn(application(1));
+        StallQualificationApplication other = application(1); other.setStoreId(9L);
+        when(mapper.selectApplication(99L)).thenReturn(other);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            assertThrows(ServiceException.class, () -> service.applicationForOwner(20L, 99L));
+            users.selectInfoUserByUserId(20L).setStatus("1");
+            assertThrows(ServiceException.class, () -> service.ownerView(20L));
+        }
+    }
+
+    @Test void reviewApprovesOnlyPendingVersionAndPreservesOldApprovalOnRejection() {
+        qualification.setCurrentApplicationId(30L); qualification.setApprovedApplicationId(11L);
+        StallQualificationApplication pending = application(1);
+        when(mapper.selectApplication(30L)).thenReturn(pending);
+        when(mapper.reviewApplication(any())).thenReturn(1);
+        QualificationReviewRequest request = new QualificationReviewRequest();
+        request.setVersion(0L); request.setApproved(false); request.setRejectReason(" incomplete ");
+        service.review(100L, 30L, request);
+        assertEquals(3, pending.getStatus()); assertEquals("incomplete", pending.getRejectReason());
+        assertEquals(11L, qualification.getApprovedApplicationId()); assertEquals(100L, pending.getReviewedBy());
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            assertThrows(ServiceException.class, () -> service.review(100L, 30L, request));
+        }
+    }
+
+    @Test void approvalSetsEffectivePointerButEmptyRejectReasonAndFailedCasDoNot() {
+        qualification.setCurrentApplicationId(30L);
+        when(mapper.selectApplication(30L)).thenReturn(application(1));
+        QualificationReviewRequest request = new QualificationReviewRequest(); request.setVersion(0L); request.setApproved(false);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            assertThrows(ServiceException.class, () -> service.review(100L, 30L, request));
+            request.setApproved(true); when(mapper.reviewApplication(any())).thenReturn(0);
+            assertThrows(ServiceException.class, () -> service.review(100L, 30L, request));
+            assertNull(qualification.getApprovedApplicationId());
+        }
+        when(mapper.selectApplication(30L)).thenReturn(application(1));
+        when(mapper.reviewApplication(any())).thenReturn(1);
+        service.review(100L, 30L, request); assertEquals(30L, qualification.getApprovedApplicationId());
+    }
+
+    private StallQualificationApplication application(int status) {
+        StallQualificationApplication value = new StallQualificationApplication();
+        value.setId(30L); value.setQualificationId(1L); value.setStoreId(8L);
+        value.setStatus(status); value.setVersion(0L); value.setApplicationVersion(1); return value;
+    }
+}

+ 59 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationStatFilterTest.java

@@ -0,0 +1,59 @@
+package com.ruoyi.app.stall.qualification;
+
+import com.alibaba.druid.filter.stat.StatFilter;
+import com.alibaba.druid.proxy.jdbc.StatementProxy;
+import com.alibaba.druid.pool.DruidDataSource;
+import com.alibaba.druid.spring.boot.autoconfigure.stat.DruidFilterConfiguration;
+import com.ruoyi.framework.config.DruidConfig;
+import com.ruoyi.framework.config.StallQualificationStatFilter;
+import com.ruoyi.framework.config.properties.DruidProperties;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.test.util.ReflectionTestUtils;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class StallQualificationStatFilterTest {
+    @Test void materialSlowParametersAreRedactedBeforeLogAndStatisticsStorage() {
+        StatementProxy statement = mock(StatementProxy.class);
+        when(statement.getLastExecuteSql()).thenReturn("INSERT INTO `STALL_QUALIFICATION_APPLICATION` (identity_number) VALUES (?)");
+        String value = ReflectionTestUtils.invokeMethod(new StallQualificationStatFilter(), "buildSlowParameters", statement);
+        assertTrue(value.contains("REDACTED"));
+        // 脱敏分支不读取任何 JDBC 参数,不能把参数先复制到监控缓存再屏蔽日志。
+        verify(statement, never()).getParameters();
+    }
+
+    @Test void productionConfigurationUsesExactlyOneRedactingStatFilterWithoutConnectingDatabase() {
+        new ApplicationContextRunner()
+                .withBean(DataSourceProperties.class, () -> {
+                    DataSourceProperties properties = new DataSourceProperties();
+                    properties.setDriverClassName("com.mysql.cj.jdbc.Driver");
+                    // 仅用于启动无连接的数据源 Bean;测试禁止调用 init/getConnection。
+                    properties.setUrl("jdbc:mysql://127.0.0.1:1/qualification_test_not_connected");
+                    return properties;
+                })
+                .withUserConfiguration(DruidConfig.class, DruidProperties.class)
+                .withConfiguration(AutoConfigurations.of(ConfigurationPropertiesAutoConfiguration.class, DruidFilterConfiguration.class))
+                .withPropertyValues("spring.datasource.druid.filter.stat.enabled=true", "spring.datasource.druid.filter.stat.log-slow-sql=true",
+                        "spring.datasource.druid.filter.stat.slow-sql-millis=1000", "spring.datasource.druid.filter.stat.merge-sql=true",
+                        "spring.datasource.druid.initialSize=0", "spring.datasource.druid.minIdle=0", "spring.datasource.druid.maxActive=1",
+                        "spring.datasource.druid.maxWait=1000", "spring.datasource.druid.connectTimeout=1000", "spring.datasource.druid.socketTimeout=1000",
+                        "spring.datasource.druid.timeBetweenEvictionRunsMillis=60000", "spring.datasource.druid.minEvictableIdleTimeMillis=300000",
+                        "spring.datasource.druid.maxEvictableIdleTimeMillis=900000", "spring.datasource.druid.validationQuery=SELECT 1",
+                        "spring.datasource.druid.testWhileIdle=false", "spring.datasource.druid.testOnBorrow=false", "spring.datasource.druid.testOnReturn=false")
+                .run(context -> {
+                    assertNull(context.getStartupFailure());
+                    assertEquals(1, context.getBeansOfType(StatFilter.class).size());
+                    StatFilter filter = context.getBean(StatFilter.class);
+                    assertInstanceOf(StallQualificationStatFilter.class, filter);
+                    assertTrue(filter.isLogSlowSql()); assertEquals(1000L, filter.getSlowSqlMillis());
+                    DruidDataSource pool = (DruidDataSource) context.getBean("masterDataSource");
+                    assertEquals(1, pool.getProxyFilters().stream().filter(StatFilter.class::isInstance).count());
+                    assertSame(filter, pool.getProxyFilters().stream().filter(StatFilter.class::isInstance).findFirst().orElseThrow());
+                    assertFalse(pool.isInited());
+                });
+    }
+}

+ 93 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationStoreGateTest.java

@@ -0,0 +1,93 @@
+package com.ruoyi.app.stall.qualification;
+
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.service.impl.PosStoreServiceImpl;
+import com.ruoyi.system.mapper.StallQualificationMapper;
+import com.ruoyi.system.service.StallQualificationGateService;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import org.mockito.MockedStatic;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class StallQualificationStoreGateTest {
+    @Test void creatingAStallCannotStartOpenBeforeQualification() {
+        PosStoreMapper mapper = mock(PosStoreMapper.class);
+        when(mapper.insertPosStore(any())).thenReturn(1);
+        PosStoreServiceImpl service = new PosStoreServiceImpl();
+        ReflectionTestUtils.setField(service, "posStoreMapper", mapper);
+        PosStore store = new PosStore(); store.setIsStall(1); store.setState(0L);
+        service.insertPosStore(store);
+        assertEquals(1L, store.getState());
+    }
+
+    @Test void legacyStallCannotOpenByFakingOrdinaryStoreIdentity() {
+        PosStoreMapper stores = mock(PosStoreMapper.class);
+        StallQualificationMapper qualifications = mock(StallQualificationMapper.class);
+        PosStore existing = new PosStore(); existing.setId(8); existing.setIsStall(1);
+        when(stores.selectPosStoreById(8L)).thenReturn(existing);
+        PosStoreServiceImpl service = new PosStoreServiceImpl();
+        ReflectionTestUtils.setField(service, "posStoreMapper", stores);
+        ReflectionTestUtils.setField(service, "stallQualificationGateService", new StallQualificationGateService(qualifications));
+        PosStore patch = new PosStore(); patch.setId(8); patch.setIsStall(0); patch.setState(0L);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("stall.qualification.approval.required")).thenReturn("notApproved");
+            assertThrows(ServiceException.class, () -> service.updatePosStore(patch));
+        }
+        verify(stores, never()).updatePosStore(any());
+        assertEquals(1, patch.getIsStall());
+        when(qualifications.hasApproved(8L)).thenReturn(true);
+        when(stores.updatePosStore(any())).thenReturn(1);
+        assertEquals(1, service.updatePosStore(patch));
+    }
+
+    @Test void closingAndEditingAnUnapprovedStallDoesNotRequireApproval() {
+        PosStoreMapper stores = mock(PosStoreMapper.class);
+        StallQualificationGateService gate = mock(StallQualificationGateService.class);
+        PosStore existing = new PosStore(); existing.setId(8); existing.setIsStall(1); existing.setState(1L); existing.setOffShelf("0");
+        when(stores.selectPosStoreById(8L)).thenReturn(existing);
+        PosStoreServiceImpl service = new PosStoreServiceImpl();
+        ReflectionTestUtils.setField(service, "posStoreMapper", stores);
+        ReflectionTestUtils.setField(service, "stallQualificationGateService", gate);
+        PosStore patch = new PosStore(); patch.setId(8); patch.setState(1L); patch.setOffShelf("0"); patch.setAddress("new address");
+        service.updatePosStore(patch);
+        verify(stores).updatePosStore(patch);
+        verifyNoInteractions(gate);
+    }
+
+    @Test void convertingOrdinaryStoreToStallStartsClosed() {
+        PosStoreMapper stores = mock(PosStoreMapper.class);
+        PosStore existing = new PosStore(); existing.setId(8); existing.setIsStall(0);
+        when(stores.selectPosStoreById(8L)).thenReturn(existing);
+        PosStoreServiceImpl service = new PosStoreServiceImpl();
+        ReflectionTestUtils.setField(service, "posStoreMapper", stores);
+        PosStore patch = new PosStore(); patch.setId(8); patch.setIsStall(1); patch.setState(0L);
+        service.updatePosStore(patch);
+        assertEquals(1L, patch.getState());
+    }
+
+    @Test void mybatisPlusSaveOrUpdateCannotBypassQualificationOrChangeStallIdentity() {
+        PosStoreMapper stores = mock(PosStoreMapper.class);
+        StallQualificationMapper qualifications = mock(StallQualificationMapper.class);
+        PosStore existing = new PosStore(); existing.setId(8); existing.setIsStall(1);
+        when(stores.selectPosStoreById(8L)).thenReturn(existing);
+        when(stores.insertOrUpdate(any(PosStore.class))).thenReturn(true);
+        PosStoreServiceImpl service = new PosStoreServiceImpl();
+        ReflectionTestUtils.setField(service, "posStoreMapper", stores);
+        ReflectionTestUtils.setField(service, "baseMapper", stores);
+        ReflectionTestUtils.setField(service, "stallQualificationGateService", new StallQualificationGateService(qualifications));
+        PosStore patch = new PosStore(); patch.setId(8); patch.setIsStall(0); patch.setState(0L);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            assertThrows(ServiceException.class, () -> service.saveOrUpdate(patch));
+        }
+        verify(stores, never()).insertOrUpdate(any(PosStore.class));
+        assertEquals(1, patch.getIsStall());
+        when(qualifications.hasApproved(8L)).thenReturn(true);
+        assertTrue(service.saveOrUpdate(patch));
+        verify(stores).insertOrUpdate(patch);
+    }
+}

+ 9 - 0
ruoyi-framework/src/main/java/com/ruoyi/framework/config/DruidConfig.java

@@ -31,6 +31,15 @@ import com.ruoyi.framework.datasource.DynamicDataSource;
 @Configuration
 public class DruidConfig
 {
+    /** 替换默认 StatFilter 而非追加第二个;沿用原慢 SQL 配置,材料参数不进入日志或监控。 */
+    @Bean
+    @ConfigurationProperties("spring.datasource.druid.filter.stat")
+    @ConditionalOnProperty(prefix = "spring.datasource.druid.filter.stat", name = "enabled")
+    public com.alibaba.druid.filter.stat.StatFilter qualificationSafeStatFilter()
+    {
+        return new StallQualificationStatFilter();
+    }
+
     @Bean
     @ConfigurationProperties("spring.datasource.druid.master")
     public DataSource masterDataSource(DruidProperties druidProperties)

+ 18 - 0
ruoyi-framework/src/main/java/com/ruoyi/framework/config/StallQualificationStatFilter.java

@@ -0,0 +1,18 @@
+package com.ruoyi.framework.config;
+
+import com.alibaba.druid.filter.stat.StatFilter;
+import com.alibaba.druid.proxy.jdbc.StatementProxy;
+import java.util.Locale;
+
+/** 仅摊位材料 SQL 参数脱敏,保留其他 SQL 的既有慢查询诊断。 */
+public class StallQualificationStatFilter extends StatFilter {
+    @Override
+    protected String buildSlowParameters(StatementProxy statement) {
+        String sql = statement.getLastExecuteSql();
+        if (sql != null && sql.toLowerCase(Locale.ROOT).contains("stall_qualification")) {
+            // Druid 同时把该返回值写入慢日志和 lastSlowParameters;不能只关闭日志仍留监控明文。
+            return "[\"REDACTED: stall qualification materials\"]";
+        }
+        return super.buildSlowParameters(statement);
+    }
+}

+ 13 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/StallQualification.java

@@ -0,0 +1,13 @@
+package com.ruoyi.system.domain;
+
+import lombok.Data;
+
+/** 摊位资质主体;账号变更不改变主体或有效材料版本。 */
+@Data
+public class StallQualification {
+    private Long id;
+    private Long storeId;
+    private Long approvedApplicationId;
+    private Long currentApplicationId;
+    private Long version;
+}

+ 37 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/StallQualificationApplication.java

@@ -0,0 +1,37 @@
+package com.ruoyi.system.domain;
+
+import lombok.Data;
+import java.util.Date;
+import java.util.List;
+
+/** 统一审核材料快照。提交后不得改写;驳回重提建立新申请。 */
+@Data
+public class StallQualificationApplication {
+    public static final int DRAFT = 0;
+    public static final int PENDING = 1;
+    public static final int APPROVED = 2;
+    public static final int REJECTED = 3;
+    private Long id;
+    private Long qualificationId;
+    private Long storeId;
+    private Integer applicationVersion;
+    private Integer status;
+    private String responsibleName;
+    private String contactPhone;
+    private String identityNumber;
+    private List<String> identityImageUrls;
+    private List<String> stallImageUrls;
+    private String foodRegistrationNumber;
+    private List<String> foodRegistrationImageUrls;
+    private String stallNameSnapshot;
+    private String stallAddressSnapshot;
+    private Long nightMarketIdSnapshot;
+    private Long submittedBy;
+    private Date submittedAt;
+    private Long reviewedBy;
+    private Date reviewedAt;
+    private String rejectReason;
+    private Long version;
+    private Date createTime;
+    private Date updateTime;
+}

+ 20 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/StallQualificationSummary.java

@@ -0,0 +1,20 @@
+package com.ruoyi.system.domain;
+
+import lombok.Data;
+import java.util.Date;
+
+/** 列表/夜市主仅返回进度,不携带姓名、电话、证件号码和附件。 */
+@Data
+public class StallQualificationSummary {
+    private Long id;
+    private Long storeId;
+    private Integer applicationVersion;
+    private Integer status;
+    private Boolean approved;
+    private String stallNameSnapshot;
+    private String stallAddressSnapshot;
+    private Long nightMarketIdSnapshot;
+    private Date submittedAt;
+    private Date reviewedAt;
+    private String rejectReason;
+}

+ 25 - 0
ruoyi-system/src/main/java/com/ruoyi/system/handler/StallImageListTypeHandler.java

@@ -0,0 +1,25 @@
+package com.ruoyi.system.handler;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.ibatis.type.BaseTypeHandler;
+import org.apache.ibatis.type.JdbcType;
+import java.sql.*;
+import java.util.List;
+
+/** 私有附件引用数组的 JSON 映射,防止逗号拼接造成错误拆分。 */
+public class StallImageListTypeHandler extends BaseTypeHandler<List<String>> {
+    private static final ObjectMapper JSON = new ObjectMapper();
+    @Override public void setNonNullParameter(PreparedStatement statement, int index, List<String> values, JdbcType jdbcType) throws SQLException {
+        try { statement.setString(index, JSON.writeValueAsString(values)); }
+        catch (Exception exception) { throw new SQLException("Invalid qualification attachment list", exception); }
+    }
+    @Override public List<String> getNullableResult(ResultSet result, String column) throws SQLException { return parse(result.getString(column)); }
+    @Override public List<String> getNullableResult(ResultSet result, int column) throws SQLException { return parse(result.getString(column)); }
+    @Override public List<String> getNullableResult(CallableStatement statement, int column) throws SQLException { return parse(statement.getString(column)); }
+    private List<String> parse(String value) throws SQLException {
+        if (value == null || value.isBlank()) { return List.of(); }
+        try { return JSON.readValue(value, new TypeReference<List<String>>() {}); }
+        catch (Exception exception) { throw new SQLException("Invalid qualification attachment list", exception); }
+    }
+}

+ 24 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/StallQualificationMapper.java

@@ -0,0 +1,24 @@
+package com.ruoyi.system.mapper;
+
+import com.ruoyi.system.domain.StallQualification;
+import com.ruoyi.system.domain.StallQualificationApplication;
+import com.ruoyi.system.domain.StallQualificationSummary;
+import org.apache.ibatis.annotations.Param;
+import java.util.List;
+
+public interface StallQualificationMapper {
+    int ensureMaster(@Param("storeId") Long storeId);
+    StallQualification selectByStoreId(@Param("storeId") Long storeId);
+    StallQualification lockByStoreId(@Param("storeId") Long storeId);
+    int updateMaster(StallQualification qualification);
+    StallQualificationApplication selectApplication(@Param("id") Long id);
+    int insertApplication(StallQualificationApplication application);
+    int updateDraft(StallQualificationApplication application);
+    int reviewApplication(StallQualificationApplication application);
+    List<StallQualificationSummary> selectHistory(@Param("storeId") Long storeId);
+    List<StallQualificationSummary> selectMarketProgress(@Param("marketId") Long marketId);
+    List<StallQualificationSummary> selectApplications(@Param("status") Integer status,
+            @Param("storeId") Long storeId, @Param("offset") int offset, @Param("limit") int limit);
+    long countApplications(@Param("status") Integer status, @Param("storeId") Long storeId);
+    boolean hasApproved(@Param("storeId") Long storeId);
+}

+ 21 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/StallQualificationGateService.java

@@ -0,0 +1,21 @@
+package com.ruoyi.system.service;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.mapper.StallQualificationMapper;
+import org.springframework.stereotype.Service;
+
+/** 存量和新增摊位一视同仁;普通商家保留原审核流程,已有订单履约不调用此门禁。 */
+@Service
+public class StallQualificationGateService {
+    private final StallQualificationMapper mapper;
+    public StallQualificationGateService(StallQualificationMapper mapper) { this.mapper = mapper; }
+    public boolean canOperate(PosStore store) {
+        return store != null && (!Integer.valueOf(1).equals(store.getIsStall())
+                || (store.getId() != null && mapper.hasApproved(store.getId().longValue())));
+    }
+    public void requireApproved(PosStore store) {
+        if (!canOperate(store)) { throw new ServiceException(MessageUtils.message("stall.qualification.approval.required")); }
+    }
+}

+ 47 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosStoreServiceImpl.java

@@ -11,6 +11,7 @@ import org.springframework.stereotype.Service;
 import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.system.domain.PosStore;
 import com.ruoyi.system.service.IPosStoreService;
+import com.ruoyi.system.service.StallQualificationGateService;
 
 /**
  * posstoreService业务层处理
@@ -23,6 +24,50 @@ public class PosStoreServiceImpl extends ServiceImpl<BaseMapper<PosStore>,PosSto
 {
     @Autowired
     private PosStoreMapper posStoreMapper;
+    @Autowired
+    private StallQualificationGateService stallQualificationGateService;
+
+    /** 开店写入口共用门禁;数据库摊位身份优先,不能靠请求修改 isStall 绕过审核。 */
+    private void checkQualification(PosStore patch, boolean creating) {
+        PosStore existing = patch.getId() == null ? null
+                : posStoreMapper.selectPosStoreById(patch.getId().longValue());
+        if (existing != null && Integer.valueOf(1).equals(existing.getIsStall())) {
+            patch.setIsStall(1);
+        }
+        if (!Integer.valueOf(1).equals(patch.getIsStall())) { return; }
+        if (creating || existing == null || !Integer.valueOf(1).equals(existing.getIsStall())) {
+            // 新摊位尚无已通过材料,不允许默认或客户端指定直接营业。
+            patch.setState(1L);
+        } else {
+            // 闭店的完整表单常携带 offShelf=0;修改名称/地址不等于开店,必须允许补材料。
+            boolean enablingVisibleOpenStore = "0".equals(patch.getOffShelf())
+                    && !"0".equals(existing.getOffShelf())
+                    && !Long.valueOf(1L).equals(patch.getState())
+                    && !Long.valueOf(1L).equals(existing.getState());
+            if (Long.valueOf(0L).equals(patch.getState()) || enablingVisibleOpenStore) {
+                stallQualificationGateService.requireApproved(existing);
+            }
+        }
+    }
+
+    @Override
+    public boolean saveOrUpdate(PosStore entity) {
+        // MyBatis-Plus 3.5.10 直接调用 mapper.insertOrUpdate,不会经过 save/updateById。
+        checkQualification(entity, false);
+        return super.saveOrUpdate(entity);
+    }
+
+    @Override
+    public boolean save(PosStore entity) {
+        checkQualification(entity, true);
+        return super.save(entity);
+    }
+
+    @Override
+    public boolean updateById(PosStore entity) {
+        checkQualification(entity, false);
+        return super.updateById(entity);
+    }
 
 
     /**
@@ -58,6 +103,7 @@ public class PosStoreServiceImpl extends ServiceImpl<BaseMapper<PosStore>,PosSto
     @Override
     public int insertPosStore(PosStore posStore)
     {
+        checkQualification(posStore, true);
         return posStoreMapper.insertPosStore(posStore);
     }
 
@@ -70,6 +116,7 @@ public class PosStoreServiceImpl extends ServiceImpl<BaseMapper<PosStore>,PosSto
     @Override
     public int updatePosStore(PosStore posStore)
     {
+        checkQualification(posStore, false);
         return posStoreMapper.updatePosStore(posStore);
     }
 

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

@@ -67,9 +67,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </select>
 
     <insert id="insertPosStore" parameterType="PosStore" useGeneratedKeys="true" keyProperty="id">
+        <!-- 034:Service 设置的新摊位闭店状态必须实际写入,不能回落到数据库营业默认值。 -->
         insert into pos_store
         <trim prefix="(" suffix=")" suffixOverrides=",">
             <if test="posName != null and posName != ''">pos_name,</if>
+            <if test="state != null">state,</if>
             <if test="image != null and image != ''">image,</if>
             <if test="posPrice != null">pos_price,</if>
             <if test="area != null and area != ''">area,</if>
@@ -92,6 +94,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="posName != null and posName != ''">#{posName},</if>
+            <if test="state != null">#{state},</if>
             <if test="image != null and image != ''">#{image},</if>
             <if test="posPrice != null">#{posPrice},</if>
             <if test="area != null and area != ''">#{area},</if>
@@ -118,6 +121,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         update pos_store
         <trim prefix="SET" suffixOverrides=",">
             <if test="posName != null and posName != ''">pos_name = #{posName},</if>
+            <if test="state != null">state = #{state},</if>
             <if test="image != null and image != ''">image = #{image},</if>
             <if test="posPrice != null">pos_price = #{posPrice},</if>
             <if test="area != null and area != ''">area = #{area},</if>

+ 87 - 0
ruoyi-system/src/main/resources/mapper/chanting/StallQualificationMapper.xml

@@ -0,0 +1,87 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.StallQualificationMapper">
+    <resultMap id="master" type="com.ruoyi.system.domain.StallQualification">
+        <id property="id" column="id"/>
+        <result property="storeId" column="store_id"/>
+        <result property="approvedApplicationId" column="approved_application_id"/>
+        <result property="currentApplicationId" column="current_application_id"/>
+        <result property="version" column="version"/>
+    </resultMap>
+    <resultMap id="application" type="com.ruoyi.system.domain.StallQualificationApplication" autoMapping="true">
+        <id property="id" column="id"/>
+        <result property="identityImageUrls" column="identity_image_urls" typeHandler="com.ruoyi.system.handler.StallImageListTypeHandler"/>
+        <result property="stallImageUrls" column="stall_image_urls" typeHandler="com.ruoyi.system.handler.StallImageListTypeHandler"/>
+        <result property="foodRegistrationImageUrls" column="food_registration_image_urls" typeHandler="com.ruoyi.system.handler.StallImageListTypeHandler"/>
+    </resultMap>
+    <insert id="ensureMaster">
+        INSERT INTO stall_qualification (store_id, version, create_time, update_time)
+        VALUES (#{storeId}, 0, NOW(), NOW()) ON DUPLICATE KEY UPDATE store_id = store_id
+    </insert>
+    <select id="selectByStoreId" resultMap="master">SELECT * FROM stall_qualification WHERE store_id = #{storeId}</select>
+    <select id="lockByStoreId" resultMap="master">SELECT * FROM stall_qualification WHERE store_id = #{storeId} FOR UPDATE</select>
+    <update id="updateMaster">
+        UPDATE stall_qualification SET current_application_id = #{currentApplicationId},
+        approved_application_id = #{approvedApplicationId}, version = version + 1, update_time = NOW()
+        WHERE id = #{id} AND version = #{version}
+    </update>
+    <select id="selectApplication" resultMap="application">SELECT * FROM stall_qualification_application WHERE id = #{id}</select>
+    <insert id="insertApplication" useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO stall_qualification_application
+        (qualification_id, store_id, application_version, status, responsible_name, contact_phone, identity_number,
+         identity_image_urls, stall_image_urls, food_registration_number, food_registration_image_urls,
+         stall_name_snapshot, stall_address_snapshot, night_market_id_snapshot, submitted_by, submitted_at,
+         version, create_time, update_time)
+        VALUES (#{qualificationId}, #{storeId}, #{applicationVersion}, #{status}, #{responsibleName}, #{contactPhone}, #{identityNumber},
+         #{identityImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+         #{stallImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler}, #{foodRegistrationNumber},
+         #{foodRegistrationImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+         #{stallNameSnapshot}, #{stallAddressSnapshot}, #{nightMarketIdSnapshot}, #{submittedBy}, #{submittedAt}, 0, NOW(), NOW())
+    </insert>
+    <!-- 仅草稿可更新;version 保护同一摊位多个账号的旧表单。 -->
+    <update id="updateDraft">
+        UPDATE stall_qualification_application SET status = #{status}, responsible_name = #{responsibleName},
+        contact_phone = #{contactPhone}, identity_number = #{identityNumber},
+        identity_image_urls = #{identityImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+        stall_image_urls = #{stallImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+        food_registration_number = #{foodRegistrationNumber},
+        food_registration_image_urls = #{foodRegistrationImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+        stall_name_snapshot = #{stallNameSnapshot}, stall_address_snapshot = #{stallAddressSnapshot},
+        night_market_id_snapshot = #{nightMarketIdSnapshot}, submitted_by = #{submittedBy}, submitted_at = #{submittedAt},
+        version = version + 1, update_time = NOW()
+        WHERE id = #{id} AND status = 0 AND version = #{version}
+    </update>
+    <!-- 不更新材料字段;同一版本审核只允许成功一次。 -->
+    <update id="reviewApplication">
+        UPDATE stall_qualification_application SET status = #{status}, reviewed_by = #{reviewedBy},
+        reviewed_at = #{reviewedAt}, reject_reason = #{rejectReason}, version = version + 1, update_time = NOW()
+        WHERE id = #{id} AND status = 1 AND version = #{version}
+    </update>
+    <sql id="summaryColumns">id, store_id, application_version, status, stall_name_snapshot, stall_address_snapshot,
+        night_market_id_snapshot, submitted_at, reviewed_at, reject_reason</sql>
+    <select id="selectHistory" resultType="com.ruoyi.system.domain.StallQualificationSummary">
+        SELECT <include refid="summaryColumns"/> FROM stall_qualification_application WHERE store_id = #{storeId} ORDER BY application_version DESC
+    </select>
+    <select id="selectMarketProgress" resultType="com.ruoyi.system.domain.StallQualificationSummary">
+        SELECT s.id AS store_id, COALESCE(a.status, -1) AS status,
+        (q.approved_application_id IS NOT NULL) AS approved
+        FROM pos_store s LEFT JOIN stall_qualification q ON q.store_id = s.id
+        LEFT JOIN stall_qualification_application a ON a.id = q.current_application_id
+        WHERE s.user_id = #{marketId} AND s.is_stall = 1 AND s.del_flag = '0'
+    </select>
+    <sql id="filter">
+        <where>
+            <if test="status != null">status = #{status}</if>
+            <if test="storeId != null">AND store_id = #{storeId}</if>
+        </where>
+    </sql>
+    <select id="selectApplications" resultType="com.ruoyi.system.domain.StallQualificationSummary">
+        SELECT <include refid="summaryColumns"/> FROM stall_qualification_application
+        <include refid="filter"/> ORDER BY id DESC LIMIT #{limit} OFFSET #{offset}
+    </select>
+    <select id="countApplications" resultType="long">SELECT COUNT(*) FROM stall_qualification_application <include refid="filter"/></select>
+    <select id="hasApproved" resultType="boolean">
+        SELECT EXISTS(SELECT 1 FROM stall_qualification q JOIN stall_qualification_application a
+        ON a.id = q.approved_application_id AND a.store_id = q.store_id AND a.status = 2 WHERE q.store_id = #{storeId})
+    </select>
+</mapper>

+ 22 - 0
specs/034-stall-qualification/contracts/api.md

@@ -0,0 +1,22 @@
+# 摊位审核接口(034)
+
+数值状态:0 草稿、1 待审核、2 通过、3 驳回;-1 仅表示未创建申请,不入库。
+
+## 商家 PC:token 请求头,@Auth 校验
+
+- `GET /stall/qualification`:只允许有效 type=4 账号;storeId 从账号反推。返回 `storeId/stallName/stallAddress/nightMarketId/version/currentApplication/approvedApplication/history`;history 只有 id/applicationVersion/status/submittedAt/reviewedAt/rejectReason。
+- `POST /stall/qualification/draft`、`POST /stall/qualification/submit`:JSON `{version,responsibleName,contactPhone,identityNumber,identityImageUrls,stallImageUrls,foodRegistrationNumber,foodRegistrationImageUrls}`。不接收 storeId 或审核字段。附件数组内只允许本摊位私有附件引用。草稿允许不完整,提交必填四组资料。
+- `GET /stall/qualification/applications/{id}`:仅本摊位可查看历史材料。
+- `GET /stall/qualification/market`:仅有效 type=3 夜市主;返回己方摊位的 `storeId/status/approved`,不返回姓名、证件号、电话和附件。
+- `POST /stall/qualification/files`:multipart file;返回私有引用 `storeId/uuid.jpg` 或 `.png`,不能当公开图片 URL 使用。
+- `GET /stall/qualification/files/{storeId}/{fileName}`:只能查看本人摊位文件,返回图片二进制且 Cache-Control=no-store。
+
+## 平台 PC:现有 Authorization 登录机制和权限
+
+- `GET /system/stallQualification`:list 权限,显式 pageNum/pageSize/status/storeId;只返回申请摘要,不返回身份材料。
+- `GET /system/stallQualification/applications/{id}`:query 权限,完整申请材料及审核信息。
+- `POST /system/stallQualification/applications/{id}/review`:review 权限,JSON `{version,approved,rejectReason}`,approved 必须明确,驳回原因最长500字且必填。操作人来自平台登录,待审核记录只可处理一次。
+- `GET /system/stallQualification/files/{storeId}/{fileName}`:query 权限,图片二进制且 Cache-Control=no-store。
+
+写入/审核失败使用项目国际化业务错误;不记录包含材料的请求/响应业务日志。JSON/绑定参数错误仅返回通用本地化参数错误,不把原始证件值、附件引用或解析异常交给全局日志。材料 Mapper WARN、Druid 材料 SQL 参数在慢日志及监控缓存中脱敏。
+分页列表默认10、最多100条。主表/申请 version 不匹配时返回冲突业务错误,前端提示重新加载,不自动重试覆盖他人资料。

+ 29 - 0
specs/034-stall-qualification/data-model.md

@@ -0,0 +1,29 @@
+# 摊位认证数据模型草案
+
+所有摊位采用相同材料字段,没有个人/公司经营类型。认证只以 `pos_store.id` 关联,摊主账号作为操作人记录。
+
+## stall_qualification
+
+- `id`:主键。
+- `store_id`:摊位 ID,唯一。
+- `approved_application_id`:当前有效的已通过申请,首次通过前为空。
+- `current_application_id`:最新草稿/申请,便于恢复填写及查看进度。
+- `version`:并发保护版本。
+- `create_time`、`update_time`:时间。
+
+主表不重复存放身份证件和全部材料,已通过数据通过 `approved_application_id` 读取。最新申请待审核或驳回不自动清空已有有效指针。
+
+## stall_qualification_application
+
+- `id`、`qualification_id`、`store_id`、`application_version`。
+- `status`:草稿、待审核、通过、驳回;数值定义在正式接口契约中统一。
+- `responsible_name`、`contact_phone`、`identity_number`、`food_registration_number`。
+- `identity_image_urls`、`stall_image_urls`、`food_registration_image_urls`:私有附件引用数组(`storeId/UUID.jpg或png`)序列化存储,不保存文件二进制,不是公开 URL。
+- 第一版只保存统一表单的四组资料,不建立商业/税籍登记、保险等补充证明字段。
+- `stall_name_snapshot`、`stall_address_snapshot`、`night_market_id_snapshot`:提交时由服务端读摊位数据固化,用于审核同一版本的实际内容。
+- `submitted_by`、`submitted_at`。
+- `reviewed_by`、`reviewed_at`、`reject_reason`。
+- `version`、`create_time`、`update_time`。
+
+同一认证主体的申请版本唯一。草稿允许修改,待审核及已经审核的申请材料不可改;驳回重提创建新版本而不是覆写驳回记录。DDL 已登记到 `updatesql/sql.md`,未执行。
+`pending_store_id` 是由 `status=1` 生成的存储列,唯一索引在数据库层保证每摊位至多一个待审核申请;业务同时锁主体行并校验版本。

+ 65 - 0
specs/034-stall-qualification/plan.md

@@ -0,0 +1,65 @@
+# 摊位资质审核实施计划
+
+**工作分支**:`test-202609v2`,不创建新分支。
+**日期**:2026-10-09
+**规格**:[spec.md](spec.md)
+**进度**:已验证(本地定向检查);四组资料、存量营业门禁及两个 PC 已实现,部署/真实联调尚未执行。见 `quickstart.md`。
+
+## 技术约束
+
+沿用 Java/JDK 21、Spring Boot、MyBatis 和 MySQL;模块依赖保持 `ruoyi-admin -> ruoyi-system`。商家 PC 和平台沿用 Vue 2、现有请求封装;敏感材料使用专用私有上传,不复用公开上传。实体、Mapper/XML 放 `ruoyi-system`,业务编排和明确请求 DTO 放 `ruoyi-admin`;登录身份采用现有认证机制,禁止信任客户端提交的操作人或归属 ID。
+
+`.specify/memory/constitution.md` 当前仍为未填写模板,本功能以项目 AGENTS.md、用户确认范围和本规格为约束,不修改全局模板。
+
+## 顺序
+
+1. 按 `spec.md` 的四组统一资料实现人工审核;不收集商业/税籍登记、保险等补充证明,不做自动查证或证照到期提醒。明确存量上线方式,补齐接口契约和验收边界。
+2. 登记认证主表、申请表、唯一约束、权限与菜单 SQL;不执行。
+3. 实现草稿保存、提交、分页查询、详情、审核及版本保护。保存草稿与正式提交使用不同校验;已提交/已审核快照不可修改。
+4. 商家 PC 新增摊位认证页面;夜市主管理页展示旗下摊位状态。平台新增摊位审核列表及材料详情、审核操作;新增文案同时维护四语言。
+5. 按已确认的存量与新增一律审核后营业策略,将有效资质校验接到开店及新订单入口,保留已存在订单履约;排查 C 端摊位与商品展示入口避免仅前端隐藏。
+6. 完成定向测试、模块编译、前端检查和变更审查;更新任务进度及部署说明。
+
+## 数据与事务
+
+主表按 `store_id` 唯一,分别指向有效已通过版本和当前申请。申请保存统一字段及服务端读取的摊位展示快照。提交和审核必须在事务内完成,结合主表锁/版本和申请状态条件更新,避免两个管理员重复审核或多个摊主同时提交。审核失败不得更新有效资料指针。
+
+已有普通商家认证字段留在原表,不把整个 InfoUser 审核系统改成摊位审核。现有通用 `setuser` 不能写入新认证状态或材料版本。
+
+## 验证
+
+- 服务校验:统一必填、草稿不完整保存、归属/禁用校验、驳回重提、重复操作与版本竞争。
+- Mapper:结果映射、提交和审核条件更新、当前申请唯一性、历史不可变及事务回滚。
+- 经营门禁:新未认证摊位、通过、修改待审核但旧资质有效、已有订单继续履约及存量策略。
+- 前端:表单统一、进度/驳回原因/历史回显、四语言键一致和 Vue 编译。
+- 实际数据库并发、文件访问权限与跨端联调如未执行,交付时明确列出,不以模拟测试代替真实验证。
+
+## 本轮执行批次(2026-10-09)
+
+沿用当前三个仓库的 `test-202609v2`,直接在本会话实施,不新建分支/工作树、不自动提交。按 executing-plans 和 test-driven-development 执行,进度写入本目录 tasks.md,而不是另建技能工作目录。
+
+### 批次一:后端资料及审核闭环
+
+- 新建 `ruoyi-system` 的 `StallQualification`、`StallQualificationApplication`、`StallQualificationMapper` 和对应 XML。主表 storeId 唯一,申请版本唯一。
+- 新建 `ruoyi-admin/com/ruoyi/app/stall/qualification` 的服务、DTO、商家 Controller 和平台 Controller;草稿状态 0、待审核 1、通过 2、驳回 3,未提交仅为查询展示态 -1。
+- `GET /stall/qualification` 返回当前申请、有效申请和不含材料的历史摘要;`POST /draft` 保存,`POST /submit` 提交,`GET /applications/{id}` 查看己方历史。
+- 平台 `/system/stallQualification` 分页列表、详情和 `/applications/{id}/review`;权限分别为 `stall:qualification:list/query/review`。
+- 保存/提交请求都携带主表 version(首次为 0)。先建立主表、锁住主表再验证版本和待审核状态;提交之后冻结申请,重提新建版本,审核条件更新只允许处理待审核版本。
+- 附件仅接收 JPEG/PNG(每张不超过 5MB、每类不超过 5 张),保存在 profile 同级独立私有目录,不进入公开 profile 映射;上传和预览都校验摊位归属或平台材料查询权限,不将身份证明放到公开接口或业务日志。
+- 定向测试 `StallQualificationServiceTest`、`StallQualificationFileServiceTest` 覆盖越权、缺资料、空草稿、版本竞争、重复提交/审核、有效版本保留、私有文件路径和非法附件。
+- DDL 及菜单登记 updatesql/sql.md,不执行数据库操作。
+
+### 批次二:两个 PC 页面
+
+- 商家新建 `src/views/StallQualification.vue`、`src/api/stallQualification.js`、私有图片组件;路由及 Aside 仅 type=4 展示;StallManage 调用夜市进度接口展示不含材料的认证状态。
+- 平台新建 `src/views/stallQualification/index.vue`、`src/api/stallQualification.js`,列表不返回证件号/图片,详情按权限加载;审核携带申请 version,驳回原因必填。
+- 两端均维护四语言;组件测试先运行缺实现的断言,再补组件和页面;使用真实 Vue 模板编译及可用构建验证。
+
+### 批次三:营业门禁及收口
+
+- 用户已明确存量同样须审核后营业。新建 `ruoyi-system/service/StallQualificationGateService`;存量和新增、无记录/草稿/待审核/驳回统一拒绝营业和新下单;有旧有效认证的新申请不影响有效资格。
+- 接入 `PosStoreServiceImpl` 的营业状态保存及新建摊位默认关闭;接入 `UserOrderController`、`PosOrderShOprateController.createOrder` 和仍可调用的 `PosOrderController.addorder` 新订单入口。`addorder` 源码标记废弃而项目指令列为仍使用,故仅补最小门禁,不复用/扩展旧逻辑。C端列表/详情将未认证摊位表示为关闭,扫码列表隐藏,不阻断既有订单履约。SQL 登记将未认证存量摊位设置为关闭,不自动批准。
+- 材料接口不标注 `@Log`,材料 Mapper 单独设 WARN 防止 SQL DEBUG/TRACE 泄露证件字段;部署环境不得覆盖该日志级别。
+- 审查发现全局绑定异常日志与 Druid 慢 SQL 参数也会泄露材料:为两个材料 Controller 加专用绑定异常处理,DruidConfig 注册唯一的专用 StatFilter,仅材料两表 SQL 参数脱敏,同时保护日志和监控缓存。
+- 当前 MyBatis-Plus 3.5.10 的 `saveOrUpdate` 直接调用 Mapper;单独覆盖该入口,不依赖 `save/updateById` 间接拦截。PosStore XML insert/update 补实际 `state` 写入,闭店完整表单编辑不能被误判为开店。
+- 运行定向 Maven 测试及受影响模块编译,检查 SQL/XML、前端编译、差异、换行和全部四语言 key;记录尚未执行的部署/浏览器联调。

+ 50 - 0
specs/034-stall-qualification/quickstart.md

@@ -0,0 +1,50 @@
+# 034 摊位审核部署与验收
+
+日期:2026-10-09。后端、商家 PC、平台 PC 均在 `test-202609v2` 原分支实施,未新建分支;按用户要求在三个仓库原分支提交和推送,具体 SHA 与远端结果以 Git 记录和最终交付回复为准;App 未修改。
+
+## 上线前准备(必须手动完成)
+
+1. 备份数据库并告知夜市主:存量与新增摊位必须提交相同材料审核,不提供营业宽限或旧账号审核自动通过。
+2. 手动执行 `updatesql/sql.md` 中「2026-10-09 034」:两张表及唯一索引、未审核存量摊位闭店、平台菜单/权限。未认证摊位 `state=1`,不修改旧订单或发起取消、退款。
+3. 审核角色需要分配 `stall:qualification:list`、`stall:qualification:query`、`stall:qualification:review`;只有列表权限看不到证件图片、不能审核。平台菜单组件为 `stallQualification/index`。
+4. 私有附件目录为 `ruoyi.profile` 的同级 `stall-qualification`;例如 profile 为 `/data/ruoyi/upload` 时,材料目录为 `/data/ruoyi/stall-qualification`。确保进程可读写,部署更新保留文件;多实例需共享同一持久目录,并限制备份访问权限。不得映射到 nginx/static/profile 公共 URL,不将证件放进旧 `/utils/Upload`。
+5. 发布后端及两个 PC 构建。新增 type=4 摊主可正常登录填材料,不用夜市主代填;多个摊主账号共享同一摊位材料。夜市主仍在「摊位列表」只查看申请状态和是否有通过版本。
+6. MyBatis 材料 Mapper 保持 WARN;不额外为材料接口加请求/响应全量日志。Druid 使用唯一的 `StallQualificationStatFilter`,材料 SQL 的参数同时在慢日志与监控参数缓存中脱敏,其他 SQL 沿用原诊断。部署配置不得另注册普通 StatFilter 或启用会打印原始 JDBC 参数的额外日志过滤器。
+
+SQL、真实数据库、服务重启及上述部署操作本轮均未执行。不要只发布页面而漏执行 DDL,也不要把本机测试通过当作已经上线。
+
+## 业务操作
+
+- 摊主:商家 PC「摊位资料审核」填写负责人姓名/电话、证件号码/图片、摊位照片、食品业者登录字号/证明。摊位名称、地址和夜市 ID 来自服务端绑定;缺少名称或地址时,先在原门店/摊位管理中补齐。
+- 草稿可不完整;提交四组材料必须齐全。每类图片最多 5 张、每张不超过 5MB,仅 JPEG/PNG;上传未完成时不能提交。食品业者资料为统一平台准入要求,不将其描述为所有台湾摊位的法定要求。
+- 待审核不能编辑/重复提交。平台审核通过或填写原因驳回;驳回后修订形成新申请版本,旧材料和审核历史不覆盖。保存使用认证主体 version,审核使用申请 version;冲突需刷新,不能自动重试覆盖别人。
+- 审核通过只开放营业资格,不自动开店;摊主仍通过原营业设置决定是否营业。已有有效材料重新申请时,原有效版本继续有效,直到新版本通过替换。
+- 无记录、草稿、首次待审核或首次驳回均不得开店、新下单;闭店时可以编辑名称/地址来补资料。用户下单、商家创建订单以及仍可调用的旧 `/addorder` 均校验,不能只信列表或前端。旧订单出餐、配送、支付后续处理不调用此新门禁。
+- 用户端列表标为关闭;夜市摊位详情返回关闭状态;扫码摊位列表隐藏未通过摊位。普通商家的旧审核流程不替换。
+
+## 本轮自动化验证
+
+使用 JDK 21 `C:/Users/99029/.jdks/graalvm-jdk-21.0.12+7.1`,仅在命令环境设置 JAVA_HOME/PATH,没有改全局 Java 设置。
+
+```powershell
+mvn -pl ruoyi-admin -am test '-Dtest=StallQualification*Test,PosOrderShOprateControllerTest,UserOrderControllerMerchantIdTest,StallControllerTest,MerchantSessionAuthAnnotationTest' '-Dsurefire.failIfNoSpecifiedTests=false' -q
+```
+
+53 个用例通过(28 个本模块用例 + 25 个受影响既有回归)。覆盖草稿/提交、版本竞争、重复审核、驳回重提、保留已通过版本、身份权限、私有文件、真实 Mapper 解析、门禁与闭店资料编辑、错误绑定隐私、Druid 生产配置替换及上传 data 返回契约。
+Druid 配置测试构造未初始化/未连接的数据源,并确认 proxyFilters 中恰好一个脱敏 StatFilter;不连接测试或生产数据库。
+
+`mvn -pl ruoyi-admin -am package -DskipTests -q` 模块编译和打包通过;该打包命令不运行测试,测试使用前述独立命令执行。
+
+两个 PC 仓库均执行 `node --test tests/*.test.cjs`:商家 5/5,平台 16/16。四语言各 50 个新 key 完全一致。平台新增 API/页面/辅助代码使用 `eslint ... --no-ignore` 检查通过。
+商家 `vue-cli-service build --mode test`、平台(命令环境设置 `NODE_OPTIONS=--openssl-legacy-provider`)`vue-cli-service build --mode staging` 均通过。
+商家构建保留既有 7 个缺 key 提示;平台保留既有两个 `.vue~` 文件的构建警告及包体积提示,未修改无关文件。平台本机依赖安装未生成或修改依赖锁文件。
+
+## 部署后仍需验收(未验证)
+
+- 真实 MySQL 建表/菜单 SQL、主表首次并发初始化、两摊主同时提交、两管理员同时审核、事务失败回滚和待审核唯一约束。
+- 浏览器真实登录、上传/预览、草稿回显、驳回重提、审核通过后手动开店、存量未认证禁止新单、旧订单继续履约。
+- 生产/测试实际文件目录持久化、多实例共享、nginx 无公共访问、停用账号和越权材料访问。
+- 实际 Druid 慢查询日志/监控页面脱敏与环境覆盖配置,私有证件不出现在应用、代理或审计日志。
+- 完整项目全量回归未运行,本轮只运行上述定向 Java 用例和已有 PC Node 测试。
+
+不得将本页的模拟/配置测试视为上述真实联调的替代。

+ 75 - 0
specs/034-stall-qualification/spec.md

@@ -0,0 +1,75 @@
+# 摊位资质提交与平台审核
+
+**工作分支**:`test-202609v2`,沿用现有分支,不创建新分支。
+**创建日期**:2026-10-09
+**进度**:已验证(本地定向测试、构建及静态审查);按用户要求在三个仓库原分支提交和推送,结果以 Git 记录为准。SQL 未执行,未部署联调。见 `quickstart.md`。
+**输入**:夜市主创建摊位及摊主账号;摊主登录后自行提交认证资料,由平台审核。认证归属摊位,独立于登录账号;使用认证主表和申请记录表。所有摊位使用完全相同的资料项目,不区分个人经营或公司经营。
+
+## 已确认范围与建议口径
+
+- 已确认:认证归属 `storeId`;一个摊位可由多个账号共同管理,认证资料只有一套。
+- 已确认:采用 `stall_qualification` 和 `stall_qualification_application` 两张表,保留审核历史及当前有效版本。
+- 已确认:所有摊位使用同一表单、同一必填规则、同一审核流程。不得新增个人/公司经营类型,不得按经营主体类型隐藏字段或改变校验。
+- 第一版采用简化的统一表单和人工审核;不收集商业/税籍登记、保险等补充证明,不做自动查证或证照到期提醒。
+- 用户未要求修改商家 App;本期页面范围为商家 PC 端和平台管理端。
+
+## 统一审核资料
+
+| 项目 | 来源/归属 | 建议提交要求 |
+|---|---|---|
+| 所属夜市、摊位名称、摊位地址 | 读取 `pos_store`,提交时由服务端固化快照 | 系统关联;摊主不能改写夜市或摊位归属 |
+| 负责人姓名 `responsibleName` | 申请资料 | 必填 |
+| 联系手机号 `contactPhone` | 申请资料,与账号登录手机号分别管理 | 必填 |
+| 身份证/护照号码 `identityNumber` | 申请资料 | 必填 |
+| 身份证/护照照片 `identityImageUrls` | 私有附件引用数组 | 必填,证件照片应完整清晰 |
+| 摊位现场照片 `stallImageUrls` | 私有附件引用数组 | 必填 |
+| 食品业者登录字号 `foodRegistrationNumber` | 申请资料 | 统一作为平台入驻必填项 |
+| 食品业者登录证明 `foodRegistrationImageUrls` | 私有附件引用数组 | 统一作为平台入驻必填项 |
+
+这份表格只定义平台入驻审核所收集的四组资料:负责人姓名/电话、身份证明、摊位信息/照片、食品业者登录字号/证明。不收集商业/税籍登记、保险等补充证明,不代表免除经营者依法应履行的义务;食品登录必填属于平台准入标准,不宣称台湾所有摊贩依法都必须具备。首期不新增董监事截图、UBO 声明等公司专属项目。银行卡、收款账户及支付凭证继续由现有支付配置维护,不在认证模块重复建立收款配置。
+
+## 用户场景与验收
+
+### US1:摊主补充并提交资料(P1)
+
+夜市主创建摊位及关联账号,摊主可登录并填写认证草稿。提交时校验统一必填资料、账号启用状态及摊位归属。提交后的资料成为确定版本,不能被普通资料修改接口覆盖。
+
+验收:缺失必填材料拒绝提交;保存草稿允许不完整;其他摊位账号提交请求必须拒绝;同一摊位多个账号并发保存时不得静默覆盖;重复提交不得产生多条待审核申请。
+
+### US2:平台审核与驳回重提(P1)
+
+有审核权限的平台管理员查看所属夜市、摊位、申请材料及提交人,执行通过或驳回;驳回原因必填。审核同一版本只允许成功一次。驳回后以新版本重新填写,不修改既有审核记录。
+
+验收:无审核权限拒绝访问材料或执行审核;通过后主表指向该申请;驳回后保留材料及原因;重复或并发审核不得产生冲突结果。
+
+### US3:查看认证进度与有效资料(P2)
+
+摊主查看自己摊位的草稿、申请状态、驳回原因和历史;夜市主查看旗下摊位认证进度;负责人证件及附件不在公开摊位接口和夜市进度列表中返回。已通过的认证在重新提交期间保持原有效版本,新申请通过才切换。
+
+验收:查看其他摊位申请拒绝;账号停用后无法继续操作;新申请驳回不覆盖此前有效资料;账号更换不删除摊位认证。
+
+## 功能要求
+
+- FR-001:认证以摊位 ID 唯一关联;账号是提交/操作身份,不是资质归属主体。
+- FR-002:所有摊位材料项目和提交校验完全一致,不设置经营主体分类字段或分类分支。
+- FR-003:草稿、待审核、通过、驳回必须清晰展示;申请状态不能与当前有效资质混为一谈。
+- FR-004:提交后材料不可变;通过、驳回均保存操作人和时间;驳回保存原因。
+- FR-005:同一摊位至多一个待审核申请;保存、提交及审核使用版本/条件更新保护。
+- FR-006:文件使用专用私有上传和鉴权读取接口,申请保存附件引用,不在数据库保存图片二进制;证件不使用公共上传目录。
+- FR-007:后端校验类型 4 账号真实绑定的 `storeId` 和账号/摊位有效状态;平台按权限操作。
+- FR-008:旧 `InfoUser.auditStatus` 不能作为摊位资质审核通过的依据,普通商家及骑手原审核功能不受此模块替换。
+- FR-009:新摊位和存量摊位统一在材料审核通过后才允许正式营业和承接新订单;已存在订单的履约不能被认证状态变更阻断。
+- FR-010:存量摊位必须补齐相同材料并通过审核;不得从旧账号审核状态自动推定通过,不设置自动通过或营业宽限期。
+- FR-011:材料不写入公共日志或公开接口;商家端、平台端新增文本统一提供四语言。
+- FR-012:DDL、权限菜单及必要数据处理 SQL 仅登记 `updatesql/sql.md`,不直接执行数据库迁移。
+
+## 存量上线规则(2026-10-09 用户确认)
+
+存量摊位与新摊位一样补材料审核,通过前不得营业或创建新订单。部署必须先执行本模块 DDL,再发布门禁代码;无认证记录也视为未通过,不根据旧账号审核状态自动通过。既有订单的出餐、配送、支付后续处理不受此门禁阻断。
+
+## 成功标准
+
+- 摊主可完成保存草稿、提交、查看审核结果、驳回重提的闭环。
+- 平台可核对每个材料版本及对应审核结果,修改和并发不丢失历史。
+- 对其他摊位的材料查询、修改及审核请求均被服务端拒绝。
+- 同一摊位增加或停用管理账号不改变已通过资质。

+ 36 - 0
specs/034-stall-qualification/tasks.md

@@ -0,0 +1,36 @@
+# 摊位资质审核任务
+
+**日期**:2026-10-09
+**分支**:`test-202609v2`,不创建新分支。
+**总进度**:已验证(本地定向测试、后端模块打包、两个 PC 构建及静态审查)。按用户要求在三个仓库原分支提交和推送,结果以 Git 记录为准;未执行 SQL、部署联调,真实环境验收见 `quickstart.md`。
+
+## Specify
+
+- [x] T001 在 `spec.md` 记录用户确认的摊位级认证、两表存储和共同管理账号关系。
+- [x] T002 在 `spec.md` 和 `data-model.md` 记录统一材料表单,不区分个人/公司、不按类型改变必填规则。
+- [x] T003 表单已简化为四组统一资料;用户确认存量和新增一律补材料审核通过后营业。
+
+## Plan / Tasks
+
+- [x] T004 在 `plan.md` 记录模块分工、事务/并发保护、前后端范围与验证方式。
+- [x] T005 已在 `contracts/api.md` 明确数值状态、接口入参/响应、权限和私有材料访问规则;`quickstart.md` 记录部署顺序和未验证项。
+
+## Implement
+
+- [x] T006 在 `updatesql/sql.md` 登记两表、索引、平台权限菜单及必要存量策略 SQL;只登记不执行。
+- [x] T007 实现 `ruoyi-system` 摊位认证实体、Mapper/XML 以及 `ruoyi-admin` 业务服务/DTO。
+- [x] T008 实现商家保存草稿/提交/查询,平台查询/通过/驳回;补权限、材料校验及并发条件更新。
+- [x] T009 在 `foodie-store` 实现摊位认证提交及状态页面、夜市主进度入口,维护四语言。
+- [x] T010 在 `foodie-admin-vue` 实现摊位审核列表与材料详情、审核和驳回入口,维护四语言。
+- [x] T011 按确认策略接入营业、新订单及展示门禁;不阻断已存在订单履约,保留普通商家原审核。
+- [x] T012 补充权限、状态、版本、重复审核/提交及门禁测试,运行 JDK 21 定向测试、模块打包和前端检查。
+- [x] T013 检查变更范围、编码换行和文档一致性,记录实际未验证项目并更新任务进度。
+
+## 验证与审查记录
+
+- 后端定向测试 53 个用例通过;后端 `mvn -pl ruoyi-admin -am package -DskipTests` 通过(打包阶段跳过测试,测试另行执行)。
+- 商家 Node 用例 5/5、平台 Node 用例 16/16;两个 PC 构建通过,既有警告见 `quickstart.md`。
+- 两端四语言每种各 50 个新 key 一致;平台新增源码 ESLint `--no-ignore` 检查通过。保持 CRLF,不改依赖清单/锁文件。
+- 独立只读审查修正:真实 saveOrUpdate 门禁、state SQL 写入、闭店资料编辑、绑定异常隐私、Druid 参数日志与监控脱敏。收口检查另补上传引用必须返回 data 的回归。
+- 2026-10-09 用户要求 commit push;交付分支为三个仓库的 `test-202609v2`,提交 SHA 与远端结果以 Git 记录和最终交付回复为准,不切换、不改写其他分支。
+- 未验证:真实 MySQL 迁移/竞争/回滚、实际权限角色菜单、浏览器及私有文件部署联调、完整全量回归;清单详见 `quickstart.md`。

+ 79 - 0
updatesql/sql.md

@@ -1892,3 +1892,82 @@ ALTER TABLE user_billing
 ALTER TABLE payment_method_config
   ADD COLUMN remark VARCHAR(500) DEFAULT NULL COMMENT '商家维度支付说明:到账时间费率等;仅展示不参与结算' AFTER enabled;
 ```
+
+## 2026-10-09 034 摊位材料审核与存量营业门禁
+
+开发者手动执行,代码不自动建表或迁移。先建表、登记菜单并停用未审核摊位营业,再部署后端与两个 PC 端。
+存量与新增摊位统一补材料审核,不从 `info_user.audit_status` 自动通过;只关闭营业,不取消、退款或阻断已有订单履约。
+资料和证件只存在私有材料接口,数据库保存附件引用;不得把私有目录加入 nginx/static 公共映射。
+
+```sql
+CREATE TABLE IF NOT EXISTS stall_qualification (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  store_id BIGINT NOT NULL COMMENT '认证主体为摊位,而非摊主账号',
+  current_application_id BIGINT DEFAULT NULL COMMENT '最新草稿或申请',
+  approved_application_id BIGINT DEFAULT NULL COMMENT '当前有效的通过版本',
+  version BIGINT NOT NULL DEFAULT 0 COMMENT '多账号编辑主体版本',
+  create_time DATETIME NOT NULL,
+  update_time DATETIME NOT NULL,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_stall_qualification_store (store_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='摊位认证主体';
+
+CREATE TABLE IF NOT EXISTS stall_qualification_application (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  qualification_id BIGINT NOT NULL,
+  store_id BIGINT NOT NULL,
+  application_version INT NOT NULL,
+  status TINYINT NOT NULL COMMENT '0草稿 1待审核 2通过 3驳回',
+  responsible_name VARCHAR(100) NOT NULL,
+  contact_phone VARCHAR(32) NOT NULL,
+  identity_number VARCHAR(64) NOT NULL,
+  identity_image_urls TEXT NOT NULL COMMENT '私有图片引用JSON数组',
+  stall_image_urls TEXT NOT NULL COMMENT '私有图片引用JSON数组',
+  food_registration_number VARCHAR(64) NOT NULL,
+  food_registration_image_urls TEXT NOT NULL COMMENT '私有图片引用JSON数组',
+  stall_name_snapshot VARCHAR(255) DEFAULT NULL,
+  stall_address_snapshot VARCHAR(1000) DEFAULT NULL,
+  night_market_id_snapshot BIGINT DEFAULT NULL,
+  submitted_by BIGINT DEFAULT NULL,
+  submitted_at DATETIME DEFAULT NULL,
+  reviewed_by BIGINT DEFAULT NULL,
+  reviewed_at DATETIME DEFAULT NULL,
+  reject_reason VARCHAR(500) DEFAULT NULL,
+  version BIGINT NOT NULL DEFAULT 0 COMMENT '审核条件更新版本',
+  create_time DATETIME NOT NULL,
+  update_time DATETIME NOT NULL,
+  pending_store_id BIGINT GENERATED ALWAYS AS (CASE WHEN status=1 THEN store_id ELSE NULL END) STORED,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_qualification_application_version (qualification_id, application_version),
+  UNIQUE KEY uk_stall_pending_application (pending_store_id),
+  KEY idx_qualification_application_store (store_id, id),
+  KEY idx_qualification_application_status (status, id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='摊位材料版本及审核记录';
+
+-- 无材料认证的存量摊位全部闭店,不用旧账号审核推定通过;重跑保留已通过摊位。
+UPDATE pos_store s SET s.state=1
+WHERE s.is_stall=1 AND NOT EXISTS (
+  SELECT 1 FROM stall_qualification q JOIN stall_qualification_application a
+    ON a.id=q.approved_application_id AND a.store_id=q.store_id AND a.status=2
+  WHERE q.store_id=s.id
+);
+
+-- 菜单名使用 route.stallQualification 四语言 key,审核权限由管理员手动分配角色。
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache,
+  menu_type, visible, status, perms, icon, create_by, create_time, remark)
+SELECT 'stallQualification', 0, 18, 'stallQualification', 'stallQualification/index', 1, 1,
+  'C', '0', '0', 'stall:qualification:list', 'form', 'admin', NOW(), '034 摊位审核'
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='stall:qualification:list');
+
+SET @stall_qualification_menu_id=(SELECT menu_id FROM sys_menu WHERE perms='stall:qualification:list' ORDER BY menu_id LIMIT 1);
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, is_frame, is_cache,
+  menu_type, visible, status, perms, icon, create_by, create_time)
+SELECT 'stallQualificationQuery', @stall_qualification_menu_id, 1, '', 1, 1,
+  'F', '0', '0', 'stall:qualification:query', '#', 'admin', NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='stall:qualification:query');
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, is_frame, is_cache,
+  menu_type, visible, status, perms, icon, create_by, create_time)
+SELECT 'stallQualificationReview', @stall_qualification_menu_id, 2, '', 1, 1,
+  'F', '0', '0', 'stall:qualification:review', '#', 'admin', NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='stall:qualification:review');
+```