Bladeren bron

完善摊位资料审核字段与App对接文档

qmj 3 dagen geleden
bovenliggende
commit
9b11e23d20

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

@@ -10,9 +10,18 @@ public class QualificationSaveRequest {
     private String responsibleName;
     private String contactPhone;
     private String identityNumber;
+    private String email;
+    private String stallIntroduction;
+    private String bankAccountName;
+    private String bankName;
+    private String bankAccountNo;
     /** 新上传传商品图片路径,旧申请可保留本摊位私有附件引用;服务端验证文件。 */
     private List<String> identityImageUrls;
     private List<String> stallImageUrls;
+    private List<String> bankProofImageUrls;
+    private List<String> businessRegistrationImageUrls;
+    private List<String> directorScreenshotImageUrls;
+    private List<String> uboDeclarationImageUrls;
     private String foodRegistrationNumber;
     private List<String> foodRegistrationImageUrls;
 }

+ 25 - 8
ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/StallQualificationService.java

@@ -68,9 +68,21 @@ public class StallQualificationService {
         application.setResponsibleName(text(request.getResponsibleName(), 100));
         application.setContactPhone(text(request.getContactPhone(), 32));
         application.setIdentityNumber(text(request.getIdentityNumber(), 64));
+        application.setEmail(text(request.getEmail(), 255));
+        application.setStallIntroduction(text(request.getStallIntroduction(), 1000));
+        application.setBankAccountName(text(request.getBankAccountName(), 100));
+        application.setBankName(text(request.getBankName(), 100));
+        application.setBankAccountNo(text(request.getBankAccountNo(), 64));
+        if (!application.getEmail().isEmpty() && !application.getEmail().matches("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")) {
+            throw error("stall.qualification.input.invalid");
+        }
         application.setFoodRegistrationNumber(text(request.getFoodRegistrationNumber(), 64));
         application.setIdentityImageUrls(images(storeId, request.getIdentityImageUrls()));
         application.setStallImageUrls(images(storeId, request.getStallImageUrls()));
+        application.setBankProofImageUrls(images(storeId, request.getBankProofImageUrls()));
+        application.setBusinessRegistrationImageUrls(images(storeId, request.getBusinessRegistrationImageUrls()));
+        application.setDirectorScreenshotImageUrls(images(storeId, request.getDirectorScreenshotImageUrls()));
+        application.setUboDeclarationImageUrls(images(storeId, request.getUboDeclarationImageUrls()));
         application.setFoodRegistrationImageUrls(images(storeId, request.getFoodRegistrationImageUrls()));
         application.setStallNameSnapshot(store.getPosName()); application.setStallAddressSnapshot(store.getAddress());
         application.setNightMarketIdSnapshot(store.getNightMarketId());
@@ -111,7 +123,7 @@ public class StallQualificationService {
 
     public StallQualificationApplication adminApplication(Long id) {
         StallQualificationApplication application = id == null ? null : mapper.selectApplication(id);
-        if (application == null) { throw error("stall.qualification.not.found"); }
+        if (application == null || Integer.valueOf(DRAFT).equals(application.getStatus())) { throw error("stall.qualification.not.found"); }
         return application;
     }
     public List<StallQualificationSummary> adminList(int pageNum, int pageSize, Integer status, Long storeId) {
@@ -121,7 +133,7 @@ public class StallQualificationService {
     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"); }
+                || (status != null && (status < PENDING || status > REJECTED))) { throw error("stall.qualification.input.invalid"); }
     }
 
     @Transactional(rollbackFor = Exception.class)
@@ -137,6 +149,8 @@ public class StallQualificationService {
                 || !Integer.valueOf(PENDING).equals(application.getStatus())
                 || !Objects.equals(application.getVersion(), request.getVersion())) { throw conflict(); }
         requireStall(stores.selectPosStoreById(application.getStoreId()));
+        // 旧版待审核申请若缺银行必填资料,不允许在新规则下直接通过;须驳回后补齐重提。
+        if (request.getApproved()) { requireMaterials(application); }
         application.setStatus(request.getApproved() ? APPROVED : REJECTED);
         application.setReviewedBy(reviewerId); application.setReviewedAt(new Date());
         application.setRejectReason(request.getApproved() ? null : reason);
@@ -168,15 +182,18 @@ public class StallQualificationService {
         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()) {
+        if (blank(application.getResponsibleName()) || blank(application.getContactPhone())
+                || blank(application.getIdentityNumber())
+                || blank(application.getBankAccountName()) || blank(application.getBankName())
+                || blank(application.getBankAccountNo())
+                || empty(application.getIdentityImageUrls()) || empty(application.getStallImageUrls())
+                || empty(application.getBankProofImageUrls())
+                || blank(application.getStallNameSnapshot()) || blank(application.getStallAddressSnapshot())) {
             throw error("stall.qualification.materials.required");
         }
     }
+    private boolean blank(String value) { return value == null || value.isBlank(); }
+    private boolean empty(List<String> value) { return value == null || value.isEmpty(); }
     private String text(String value, int max) {
         String normalized = value == null ? "" : value.trim();
         if (normalized.length() > max) { throw error("stall.qualification.input.invalid"); }

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

@@ -40,6 +40,10 @@ class StallQualificationMapperTest {
         BoundSql insert = sql(configuration, "insertApplication", application);
         assertTrue(insert.getParameterMappings().stream().anyMatch(parameter ->
                 "identityImageUrls".equals(parameter.getProperty()) && parameter.getTypeHandler() instanceof StallImageListTypeHandler));
+        assertTrue(insert.getParameterMappings().stream().anyMatch(parameter ->
+                "bankProofImageUrls".equals(parameter.getProperty()) && parameter.getTypeHandler() instanceof StallImageListTypeHandler));
+        assertTrue(insert.getSql().contains("bank_account_no"));
+        assertTrue(sql(configuration, "updateDraft", application).getSql().contains("bank_account_no"));
         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"));
@@ -55,6 +59,8 @@ class StallQualificationMapperTest {
             assertFalse(query.contains("identity_number")); assertFalse(query.contains("responsible_name"));
             assertFalse(query.contains("contact_phone")); assertFalse(query.contains("image_urls"));
         }
+        assertTrue(list.contains("status != 0"));
+        assertTrue(sql(configuration, "countApplications", Map.of("status", 1)).getSql().contains("status != 0"));
         assertTrue(list.contains("LIMIT ? OFFSET ?")); assertTrue(market.contains("s.user_id = ?"));
     }
 

+ 49 - 3
ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationServiceTest.java

@@ -53,7 +53,9 @@ class StallQualificationServiceTest {
         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;
+        request.setFoodRegistrationImageUrls(List.of("8/food.jpg"));
+        request.setBankAccountName("owner"); request.setBankName("bank"); request.setBankAccountNo("123456");
+        request.setBankProofImageUrls(List.of("8/bank.jpg")); return request;
     }
 
     @Test void emptyDraftCanBeSavedButCannotBeSubmitted() {
@@ -76,6 +78,21 @@ class StallQualificationServiceTest {
         assertNotNull(application.getSubmittedAt());
     }
 
+    @Test void foodRegistrationAndEmailAreOptionalButBankDetailsAreRequired() {
+        QualificationSaveRequest request = complete();
+        request.setFoodRegistrationNumber(null); request.setFoodRegistrationImageUrls(null);
+        StallQualificationApplication saved = service.save(20L, request, true).getCurrentApplication();
+        assertEquals("", saved.getFoodRegistrationNumber());
+        assertEquals(List.of(), saved.getFoodRegistrationImageUrls());
+        assertEquals("123456", saved.getBankAccountNo());
+
+        qualification.setVersion(0L); qualification.setCurrentApplicationId(null);
+        request.setBankProofImageUrls(null);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            assertThrows(ServiceException.class, () -> service.save(20L, request, true));
+        }
+    }
+
     @Test void submitPreservesProductImagePathsInTheApplicationSnapshot() throws Exception {
         java.nio.file.Path image = directory.resolve("upload/2026/10/09/photo_001.png");
         java.nio.file.Files.createDirectories(image.getParent());
@@ -85,11 +102,12 @@ class StallQualificationServiceTest {
         String reference = "/profile/upload/2026/10/09/photo_001.png";
         QualificationSaveRequest request = complete();
         request.setIdentityImageUrls(List.of(reference)); request.setStallImageUrls(List.of(reference));
-        request.setFoodRegistrationImageUrls(List.of(reference));
+        request.setFoodRegistrationImageUrls(List.of(reference)); request.setBankProofImageUrls(List.of(reference));
         StallQualificationApplication saved = actual.save(20L, request, true).getCurrentApplication();
         assertEquals(List.of(reference), saved.getIdentityImageUrls());
         assertEquals(List.of(reference), saved.getStallImageUrls());
         assertEquals(List.of(reference), saved.getFoodRegistrationImageUrls());
+        assertEquals(List.of(reference), saved.getBankProofImageUrls());
         assertEquals(1, saved.getStatus());
     }
 
@@ -132,6 +150,14 @@ class StallQualificationServiceTest {
         }
     }
 
+    @Test void platformCannotListOrOpenDraftApplications() {
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            assertThrows(ServiceException.class, () -> service.adminList(1, 10, 0, null));
+            when(mapper.selectApplication(31L)).thenReturn(application(0));
+            assertThrows(ServiceException.class, () -> service.adminApplication(31L));
+        }
+    }
+
     @Test void reviewApprovesOnlyPendingVersionAndPreservesOldApprovalOnRejection() {
         qualification.setCurrentApplicationId(30L); qualification.setApprovedApplicationId(11L);
         StallQualificationApplication pending = application(1);
@@ -162,9 +188,29 @@ class StallQualificationServiceTest {
         service.review(100L, 30L, request); assertEquals(30L, qualification.getApprovedApplicationId());
     }
 
+    @Test void oldPendingApplicationWithoutBankDetailsCannotBeApproved() {
+        qualification.setCurrentApplicationId(30L);
+        StallQualificationApplication pending = application(1);
+        pending.setBankAccountName(null); pending.setBankName(null); pending.setBankAccountNo(null);
+        pending.setBankProofImageUrls(null);
+        when(mapper.selectApplication(30L)).thenReturn(pending);
+        QualificationReviewRequest request = new QualificationReviewRequest();
+        request.setVersion(0L); request.setApproved(true);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            assertThrows(ServiceException.class, () -> service.review(100L, 30L, request));
+        }
+        verify(mapper, never()).reviewApplication(any());
+    }
+
     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;
+        value.setStatus(status); value.setVersion(0L); value.setApplicationVersion(1);
+        value.setResponsibleName("owner"); value.setContactPhone("0912"); value.setIdentityNumber("A123");
+        value.setIdentityImageUrls(List.of("8/id.jpg")); value.setStallImageUrls(List.of("8/stall.jpg"));
+        value.setBankAccountName("owner"); value.setBankName("bank"); value.setBankAccountNo("123");
+        value.setBankProofImageUrls(List.of("8/bank.jpg"));
+        value.setStallNameSnapshot("stall"); value.setStallAddressSnapshot("address");
+        return value;
     }
 }

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

@@ -19,9 +19,18 @@ public class StallQualificationApplication {
     private String responsibleName;
     private String contactPhone;
     private String identityNumber;
+    private String email;
+    private String stallIntroduction;
+    private String bankAccountName;
+    private String bankName;
+    private String bankAccountNo;
     /** 商品上传返回的 /profile/upload 图片路径;兼容历史 storeId/UUID 私有附件引用。 */
     private List<String> identityImageUrls;
     private List<String> stallImageUrls;
+    private List<String> bankProofImageUrls;
+    private List<String> businessRegistrationImageUrls;
+    private List<String> directorScreenshotImageUrls;
+    private List<String> uboDeclarationImageUrls;
     private String foodRegistrationNumber;
     private List<String> foodRegistrationImageUrls;
     private String stallNameSnapshot;

+ 21 - 3
ruoyi-system/src/main/resources/mapper/chanting/StallQualificationMapper.xml

@@ -12,6 +12,10 @@
         <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="bankProofImageUrls" column="bank_proof_image_urls" typeHandler="com.ruoyi.system.handler.StallImageListTypeHandler"/>
+        <result property="businessRegistrationImageUrls" column="business_registration_image_urls" typeHandler="com.ruoyi.system.handler.StallImageListTypeHandler"/>
+        <result property="directorScreenshotImageUrls" column="director_screenshot_image_urls" typeHandler="com.ruoyi.system.handler.StallImageListTypeHandler"/>
+        <result property="uboDeclarationImageUrls" column="ubo_declaration_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">
@@ -29,12 +33,19 @@
     <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,
+         email, stall_introduction, bank_account_name, bank_name, bank_account_no,
+         identity_image_urls, stall_image_urls, bank_proof_image_urls, business_registration_image_urls,
+         director_screenshot_image_urls, ubo_declaration_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},
+         #{email}, #{stallIntroduction}, #{bankAccountName}, #{bankName}, #{bankAccountNo},
          #{identityImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
-         #{stallImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler}, #{foodRegistrationNumber},
+         #{stallImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+         #{bankProofImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+         #{businessRegistrationImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+         #{directorScreenshotImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+         #{uboDeclarationImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler}, #{foodRegistrationNumber},
          #{foodRegistrationImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
          #{stallNameSnapshot}, #{stallAddressSnapshot}, #{nightMarketIdSnapshot}, #{submittedBy}, #{submittedAt}, 0, NOW(), NOW())
     </insert>
@@ -42,8 +53,14 @@
     <update id="updateDraft">
         UPDATE stall_qualification_application SET status = #{status}, responsible_name = #{responsibleName},
         contact_phone = #{contactPhone}, identity_number = #{identityNumber},
+        email = #{email}, stall_introduction = #{stallIntroduction},
+        bank_account_name = #{bankAccountName}, bank_name = #{bankName}, bank_account_no = #{bankAccountNo},
         identity_image_urls = #{identityImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
         stall_image_urls = #{stallImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+        bank_proof_image_urls = #{bankProofImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+        business_registration_image_urls = #{businessRegistrationImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+        director_screenshot_image_urls = #{directorScreenshotImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+        ubo_declaration_image_urls = #{uboDeclarationImageUrls,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},
@@ -71,7 +88,8 @@
     </select>
     <sql id="filter">
         <where>
-            <if test="status != null">status = #{status}</if>
+            status != 0
+            <if test="status != null">AND status = #{status}</if>
             <if test="storeId != null">AND store_id = #{storeId}</if>
         </where>
     </sql>

+ 82 - 0
specs/034-stall-qualification/app-frontend-adjustment.md

@@ -0,0 +1,82 @@
+# 摊位资料审核:App 前端对接说明(2026-10-16)
+
+本说明供商家 App 前端对接摊位资料审核。摊位资料属于摊位,不属于某个登录账号;摊主账号(`userType=4`)可填写、提交和查看本摊位申请。夜市主账号(`userType=3`)只能通过进度接口查看旗下摊位状态。普通商家资料页不因此改变。本次只交付后端与两个 PC 的代码及 App 对接文档,App 页面代码需由 App 前端实现。
+
+## 页面字段
+
+顶部照片使用已有的摊位现场照片 `stallImageUrls`。摊位名称、地址、所属夜市由 `GET /stall/qualification` 返回,页面只展示,不作为保存参数提交。所有摊位使用同一套字段和必填规则,不区分个人或公司经营。
+
+| 页面项目 | 请求字段 | 提交时要求 | 限制 |
+|---|---|---|---|
+| 负责人姓名 | `responsibleName` | 必填 | 最多 100 字符 |
+| 手机号码 | `contactPhone` | 必填 | 最多 32 字符 |
+| 身份证/护照号码 | `identityNumber` | 必填 | 最多 64 字符 |
+| 身份证/护照图片 | `identityImageUrls` | 至少 1 张 | 图片数组 |
+| 摊位现场照片 | `stallImageUrls` | 至少 1 张 | 图片数组 |
+| 银行账户户名 | `bankAccountName` | 必填 | 最多 100 字符 |
+| 银行名称 | `bankName` | 必填 | 最多 100 字符;见下方银行列表 |
+| 银行账号 | `bankAccountNo` | 必填 | 最多 64 字符 |
+| 银行账户证明 | `bankProofImageUrls` | 至少 1 张 | 图片数组 |
+| 电子邮箱 | `email` | 选填 | 最多 255 字符;填写时须为有效邮箱 |
+| 摊位介绍 | `stallIntroduction` | 选填 | 最多 1000 字符 |
+| 营业登记证明 | `businessRegistrationImageUrls` | 选填 | 图片数组 |
+| 董监事资料查询截图 | `directorScreenshotImageUrls` | 选填 | 图片数组 |
+| 食品业者登录字号 | `foodRegistrationNumber` | 选填 | 最多 64 字符 |
+| 食品业者登录证明 | `foodRegistrationImageUrls` | 选填 | 图片数组 |
+| 实质受益人声明(UBO) | `uboDeclarationImageUrls` | 选填 | 图片数组 |
+
+每组图片最多 5 张,每张最多 5 MB,仅支持 JPEG/PNG。草稿允许缺必填项;提交时 App 应先提示缺失项,服务端仍会再次校验。选填文本可传空字符串,选填图片可传空数组。银行四项只作为审核材料快照保存,不会自动修改实际收款银行卡或支付设置。
+
+## 接口与数据流
+
+摊位资料接口沿用商家 App 的 `token` 请求头;成功响应为 `{ "code": 200, "msg": "...", "data": ... }`。`storeId` 从登录账号的摊位绑定推导,保存时不要传 `storeId`、状态、审核人、摊位名称或地址。
+
+1. `GET /stall/qualification`:进入页面及保存、提交后刷新资料。读取 `data.version` 作为下一次保存或提交请求的版本;首次没有申请时为 `0`。`data.currentApplication` 是当前申请,`data.approvedApplication` 是当前有效的已通过申请,二者可能不是同一版本。还返回 `storeId`、`stallName`、`stallAddress`、`nightMarketId` 和 `history`。`history` 是摘要,查看某条完整材料再调用详情接口。
+2. `GET /infouser/user/bankList`:银行名称下拉数据来自 `data` 数组,选项显示 `dictLabel`,保存 `dictLabel` 到 `bankName`,与商家 PC 一致。
+3. `POST /utils/Upload`:使用 `multipart/form-data`,文件字段名 `file`。成功后把响应的 `data`(例如 `/profile/upload/2026/10/16/photo.png`)放入对应图片数组;保存路径字符串,不传图片二进制或完整外部 URL。上传中禁止保存、提交。公共路径可按后端域名加返回路径预览。
+4. `POST /stall/qualification/draft`:JSON body 保存草稿。即使资料不完整也可保存;填写了邮箱或上传了图片时仍须符合格式和文件规则。
+5. `POST /stall/qualification/submit`:与草稿使用相同 JSON body,服务端校验必填资料并提交审核。成功后使用返回的 `data.version` 和 `data.currentApplication` 更新页面。
+6. `GET /stall/qualification/applications/{id}`:点击 `history` 中的某条记录时,读取该版本完整资料与图片;只能查看本人摊位。历史申请只读。
+7. `GET /stall/qualification/market`:如夜市主 App 要展示旗下摊位进度,可使用此接口;只返回进度摘要,不返回负责人、证件及图片。
+
+保存或提交请求示例(图片路径和填写内容仅为格式示例):
+
+```json
+{
+  "version": 0,
+  "responsibleName": "负责人姓名",
+  "contactPhone": "09xxxxxxxx",
+  "identityNumber": "证件号码",
+  "email": "",
+  "stallIntroduction": "",
+  "bankAccountName": "账户户名",
+  "bankName": "银行名称",
+  "bankAccountNo": "银行账号",
+  "identityImageUrls": ["/profile/upload/2026/10/16/id.png"],
+  "stallImageUrls": ["/profile/upload/2026/10/16/stall.png"],
+  "bankProofImageUrls": ["/profile/upload/2026/10/16/bank.png"],
+  "businessRegistrationImageUrls": [],
+  "directorScreenshotImageUrls": [],
+  "foodRegistrationNumber": "",
+  "foodRegistrationImageUrls": [],
+  "uboDeclarationImageUrls": []
+}
+```
+
+## 状态与操作
+
+| `currentApplication.status` | 页面显示 | 可操作行为 |
+|---|---|---|
+| `null`(无申请) | 未提交 | 填写、保存草稿、提交 |
+| `0` | 草稿 | 编辑、保存草稿、提交 |
+| `1` | 待审核 | 展示已提交资料,不允许修改或重复提交 |
+| `2` | 已通过 | 展示审核结果;再次修改时会创建新申请版本,旧有效版本在新申请通过前继续有效 |
+| `3` | 已驳回 | 展示 `rejectReason`;修改后保存或重新提交会创建新申请版本 |
+
+不要仅凭当前申请状态判断是否已有有效资质:应看 `approvedApplication` 是否存在。审核通过也不会自动设置摊位营业状态。遇到版本冲突时重新调用 `GET /stall/qualification`,让用户确认最新资料后再操作,不能自动覆盖。旧待审核申请如果缺少新增的银行资料,平台不能直接审核通过,需要驳回后由摊主补齐重提。
+
+## 图片历史兼容与上线前提
+
+新上传统一使用 `/utils/Upload`。历史资料可能包含旧私有引用(如 `storeId/UUID.jpg`),这种引用用 `GET /stall/qualification/files/{storeId}/{fileName}` 携带 `token` 读取并显示;不要把它拼成公开图片 URL。图片链接可能包含证件材料,不要把完整申请、图片路径或请求体写入普通日志。
+
+后端读取新字段前,须先由开发者手动执行 `updatesql/sql.md` 末尾的 **2026-10-16 摊位资料审核补充字段(034)** SQL。本轮未执行数据库迁移或真实 App 联调;App 前端完成后应验证新建、草稿回显、提交、待审核只读、驳回重提、历史图片和银行下拉。

+ 6 - 4
specs/034-stall-qualification/contracts/api.md

@@ -1,11 +1,13 @@
 # 摊位审核接口(034)
 
+商家 App 前端字段、页面状态及调用顺序见 [App 前端对接说明](../app-frontend-adjustment.md)。
+
 数值状态: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 或审核字段。图片数组传商品上传返回的 `/profile/upload/...` 路径,兼容本摊位旧私有附件引用。公共路径必须是系统实际存在的 JPEG/PNG(最多5MB、2000万像素),拒绝外部 URL/路径穿越/伪图片。草稿允许不完整,提交必填四组资料。
+- `POST /stall/qualification/draft`、`POST /stall/qualification/submit`:JSON `{version,responsibleName,contactPhone,identityNumber,email?,stallIntroduction?,bankAccountName,bankName,bankAccountNo,identityImageUrls,stallImageUrls,bankProofImageUrls,businessRegistrationImageUrls?,directorScreenshotImageUrls?,uboDeclarationImageUrls?,foodRegistrationNumber?,foodRegistrationImageUrls?}`。不接收 storeId 或审核字段。图片数组传商品上传返回的 `/profile/upload/...` 路径,兼容本摊位旧私有附件引用。公共路径必须是系统实际存在的 JPEG/PNG(最多5MB、2000万像素),拒绝外部 URL/路径穿越/伪图片。草稿允许不完整;提交时负责人、证件、摊位照片和银行四项必填。电子邮箱填写时校验格式。
 - `GET /stall/qualification/applications/{id}`:仅本摊位可查看历史材料。
 - `GET /stall/qualification/market`:仅有效 type=3 夜市主;返回己方摊位的 `storeId/status/approved`,不返回姓名、证件号、电话和附件。
 - 新图片:复用商品 `POST /utils/Upload`,multipart 的 `file`;返回 `{code:200,data:"/profile/upload/YYYY/MM/DD/文件名.png"}`,后端会把文件名中 `/ \ : * ? " < > | ; # % ,` 及控制字符规范为 `_`。商家前端用商品同款 `el-upload` 直传并按 `VUE_APP_BASE_URL + data` 展示,平台直接用共享 `ImagePreview` 解析路径。无需额外调用审核文件接口,图片 GET 无需登录,用户已确认包括证件在内的链接公开访问。
@@ -14,9 +16,9 @@
 
 ## 平台 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`:list 权限,显式 pageNum/pageSize/status/storeId;仅列出待审核、已通过、已驳回(status=1/2/3),清空筛选也不返回草稿,显式请求 status=0 拒绝。只返回申请摘要,不返回身份材料。
+- `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 参数在慢日志及监控缓存中脱敏。

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

@@ -79,3 +79,12 @@
 3. 商家将公共路径按商品后端域名展示,平台直接交给商品 `ImagePreview`;只有历史私有引用请求原文件接口,释放资源时只 revoke 临时 blob。
 4. 不迁移历史文件、不回写已提交申请;已有私有图片若服务器缺失或原权限/代理异常,仍需单独处理,本增量不宣称修复旧文件部署问题。原私有上传/读取端点仅保留兼容,新 PC 不再使用私有上传。
 5. 回归覆盖真实公共图片验证、保存/提交快照路径、新公共与旧私有混合预览、商品上传请求及忙锁;运行后端定向/全量测试、模块构建和两端全部 Node 用例/构建,检查 CRLF 和变更范围。数据库、App、部署与真浏览器联调不执行。
+
+## 2026-10-16 审核资料扩展与平台草稿过滤
+
+1. 在原申请快照表增加邮箱、摊位介绍、银行账户三项文本及银行证明、营业登记、董监事截图、UBO 四组图片;原食品字段保留并改为选填。增量 SQL 追加在 `updatesql/sql.md` 最后,不直接执行。历史申请不回填、不撤销已通过资格。
+2. 商家 PC 用现有银行字典下拉及商品图片上传控件填写新字段;草稿可不完整,提交时后端复核负责人、证件、摊位照片和银行四项。选填材料允许为空,全部图片仍按现有安全规则核验。
+3. 平台 PC 详情展示新资料,列表筛选只提供待审核、通过、驳回;服务端列表与计数始终排除草稿,平台详情也拒绝草稿。摊主端草稿和历史流程保持。
+4. 更新两端四语言、034 规格与接口文档;运行后端定向/回归与模块构建、两端 Node 测试和构建、差异与 CRLF 检查。银行审核快照不自动同步实际收款卡。
+
+本地验证:JDK 21 后端全量测试及打包通过,商家 PC Node 11/11、`build:test` 通过,平台 PC Node 28/28、`build:stage` 通过。旧待审核记录缺银行字段时审核通过会返回材料不完整错误;待审核记录仍可驳回后重提。SQL、部署及真实浏览器联调未执行。

+ 4 - 4
specs/034-stall-qualification/quickstart.md

@@ -5,8 +5,8 @@
 ## 上线前准备(必须手动完成)
 
 1. 备份数据库并告知夜市主:存量与新增摊位必须提交相同材料审核,不提供营业宽限或旧账号审核自动通过。
-2. 手动执行 `updatesql/sql.md` 中「2026-10-09 034」:两张表及唯一索引、未审核存量摊位闭店、平台菜单/权限。未认证摊位 `state=1`,不修改旧订单或发起取消、退款。
-3. 审核角色需要分配 `stall:qualification:list`、`stall:qualification:query`、`stall:qualification:review`;只有列表权限看不到证件图片、不能审核。平台菜单组件为 `stallQualification/index`。
+2. 手动执行 `updatesql/sql.md` 中「2026-10-09 034」:两张表及唯一索引、未审核存量摊位闭店、平台菜单/权限;再执行文档末尾「2026-10-16 摊位资料审核补充字段」增量。未认证摊位 `state=1`,不修改旧订单或发起取消、退款。
+3. 审核角色需要分配 `stall:qualification:list`、`stall:qualification:query`、`stall:qualification:review`;只有列表权限看不到证件图片、不能审核。平台菜单组件为 `stallQualification/index`,平台列表不展示草稿。
 4. 按最新确认,新材料与商品共用 `/utils/Upload`,保存在 `ruoyi.profile/upload`,返回 `/profile/upload/...` 路径;身份证等图片链接也公开访问,用户已确认。确保商品上传/图片访问路由正常、目录持久化并在多实例共享。原 `ruoyi.profile` 同级 `stall-qualification` 目录只用于历史私有附件;须继续保留,不迁移或回写旧申请,也不为兼容旧附件开放该目录的公共映射。
 5. 发布后端及两个 PC 构建。新增 type=4 摊主可正常登录填材料,不用夜市主代填;多个摊主账号共享同一摊位材料。夜市主仍在「摊位列表」只查看申请状态和是否有通过版本。
 6. MyBatis 材料 Mapper 保持 WARN;不额外为材料接口加请求/响应全量日志。Druid 使用唯一的 `StallQualificationStatFilter`,材料 SQL 的参数同时在慢日志与监控参数缓存中脱敏,其他 SQL 沿用原诊断。部署配置不得另注册普通 StatFilter 或启用会打印原始 JDBC 参数的额外日志过滤器。
@@ -15,8 +15,8 @@ SQL、真实数据库、服务重启及上述部署操作本轮均未执行。
 
 ## 业务操作
 
-- 摊主:商家 PC「摊位资料审核」填写负责人姓名/电话、证件号码/图片、摊位照片、食品业者登录字号/证明。摊位名称、地址和夜市 ID 来自服务端绑定;缺少名称或地址时,先在原门店/摊位管理中补齐。
-- 草稿可不完整;提交四组材料必须齐全。每类图片最多 5 张、每张不超过 5MB,仅 JPEG/PNG;上传未完成时不能提交。食品业者资料为统一平台准入要求,不将其描述为所有台湾摊位的法定要求。
+- 摊主:商家 PC「摊位资料审核」填写负责人姓名/电话、证件号码/图片、摊位照片和银行账户户名、银行名称、银行账号、银行证明图片(均必填);邮箱、摊位介绍、营业登记证明、董监事截图、食品业者登录字号/证明及 UBO 声明选填。摊位名称、地址和夜市 ID 来自服务端绑定;缺少名称或地址时,先在原门店/摊位管理中补齐。
+- 草稿可不完整;提交时负责人、证件及摊位照片、银行账户四项必须齐全,食品业者登录资料选填。每类图片最多 5 张、每张不超过 5MB,仅 JPEG/PNG;上传未完成时不能提交。
 - 待审核不能编辑/重复提交。平台审核通过或填写原因驳回;驳回后修订形成新申请版本,旧材料和审核历史不覆盖。保存使用认证主体 version,审核使用申请 version;冲突需刷新,不能自动重试覆盖别人。
 - 审核通过只开放营业资格,不自动开店;摊主仍通过原营业设置决定是否营业。已有有效材料重新申请时,原有效版本继续有效,直到新版本通过替换。
 - 无记录、草稿、首次待审核或首次驳回均不得开店、新下单;闭店时可以编辑名称/地址来补资料。用户下单、商家创建订单以及仍可调用的旧 `/addorder` 均校验,不能只信列表或前端。旧订单出餐、配送、支付后续处理不调用此新门禁。

+ 14 - 9
specs/034-stall-qualification/spec.md

@@ -2,7 +2,7 @@
 
 **工作分支**:`test-202609v2`,沿用现有分支,不创建新分支。
 **创建日期**:2026-10-09
-**进度**:已验证(本地定向测试、构建及静态审查;全量回归保留1项既有泰文失败);最新商品图片上传增量尚未提交/推送。此前提交以 Git 记录为准,SQL 未执行,未部署联调。见 `tasks.md` 和 `quickstart.md`。
+**进度**:原有流程及 2026-10-16 新资料字段、平台草稿筛选调整均已通过本地测试和构建;提交以 Git 记录为准。SQL 未执行,未部署联调。
 **输入**:夜市主创建摊位及摊主账号;摊主登录后自行提交认证资料,由平台审核。认证归属摊位,独立于登录账号;使用认证主表和申请记录表。所有摊位使用完全相同的资料项目,不区分个人经营或公司经营。
 
 ## 已确认范围与建议口径
@@ -10,8 +10,8 @@
 - 已确认:认证归属 `storeId`;一个摊位可由多个账号共同管理,认证资料只有一套。
 - 已确认:采用 `stall_qualification` 和 `stall_qualification_application` 两张表,保留审核历史及当前有效版本。
 - 已确认:所有摊位使用同一表单、同一必填规则、同一审核流程。不得新增个人/公司经营类型,不得按经营主体类型隐藏字段或改变校验。
-- 第一版采用简化的统一表单和人工审核;不收集商业/税籍登记、保险等补充证明,不做自动查证或证照到期提醒。
-- 用户未要求修改商家 App;本期页面范围为商家 PC 端和平台管理端。
+- 2026-10-16 起按商家资料补充银行信息及选填证明,仍使用统一表单和人工审核;不做自动查证或证照到期提醒。
+- 本期页面代码范围为商家 PC 端和平台管理端;App 前端对接要求见 `app-frontend-adjustment.md`,本仓库不含 App 页面代码。
 
 ## 统一审核资料
 
@@ -23,10 +23,14 @@
 | 身份证/护照号码 `identityNumber` | 申请资料 | 必填 |
 | 身份证/护照照片 `identityImageUrls` | 商品上传图片路径数组,兼容旧私有引用 | 必填,证件照片应完整清晰 |
 | 摊位现场照片 `stallImageUrls` | 商品上传图片路径数组,兼容旧私有引用 | 必填 |
-| 食品业者登录字号 `foodRegistrationNumber` | 申请资料 | 统一作为平台入驻必填项 |
-| 食品业者登录证明 `foodRegistrationImageUrls` | 商品上传图片路径数组,兼容旧私有引用 | 统一作为平台入驻必填项 |
+| 电子邮箱 `email`、摊位介绍 `stallIntroduction` | 申请资料 | 选填,邮箱填写时校验格式 |
+| 银行账户户名 `bankAccountName`、银行名称 `bankName`、银行账号 `bankAccountNo` | 申请资料 | 均必填 |
+| 银行账户证明 `bankProofImageUrls` | 商品上传图片路径数组,兼容旧私有引用 | 必填 |
+| 营业登记证明 `businessRegistrationImageUrls`、董监事资料查询截图 `directorScreenshotImageUrls`、UBO 声明 `uboDeclarationImageUrls` | 商品上传图片路径数组,兼容旧私有引用 | 均选填,不区分个人/公司摊位 |
+| 食品业者登录字号 `foodRegistrationNumber` | 申请资料 | 选填 |
+| 食品业者登录证明 `foodRegistrationImageUrls` | 商品上传图片路径数组,兼容旧私有引用 | 选填 |
 
-这份表格只定义平台入驻审核所收集的四组资料:负责人姓名/电话、身份证明、摊位信息/照片、食品业者登录字号/证明。不收集商业/税籍登记、保险等补充证明,不代表免除经营者依法应履行的义务;食品登录必填属于平台准入标准,不宣称台湾所有摊贩依法都必须具备。首期不新增董监事截图、UBO 声明等公司专属项目。银行卡、收款账户及支付凭证继续由现有支付配置维护,不在认证模块重复建立收款配置。
+顶部商家照片对应已有的摊位现场照片;摊位名称、地址及所属夜市继续由服务端绑定,不接受客户端改写。银行四项在审核申请中保存为材料快照,实际收款卡与支付配置仍由现有模块维护,不因审核申请自动改变;已有通过的历史申请保持有效,旧待审核申请缺银行资料时须驳回补齐重提,不能直接通过。所有选填证明使用同一套图片安全校验,不代表免除经营者依法应履行的义务。
 
 ## 用户场景与验收
 
@@ -51,8 +55,8 @@
 ## 功能要求
 
 - FR-001:认证以摊位 ID 唯一关联;账号是提交/操作身份,不是资质归属主体。
-- FR-002:所有摊位材料项目和提交校验完全一致,不设置经营主体分类字段或分类分支。
-- FR-003:草稿、待审核、通过、驳回必须清晰展示;申请状态不能与当前有效资质混为一谈。
+- FR-002:所有摊位材料项目和提交校验完全一致,不设置经营主体分类字段或分类分支;董监事截图与 UBO 声明对全部摊位均为选填。
+- FR-003:摊主端清晰展示草稿、待审核、通过、驳回;平台端仅展示已提交申请,不展示草稿;申请状态不能与当前有效资质混为一谈。
 - FR-004:提交后材料不可变;通过、驳回均保存操作人和时间;驳回保存原因。
 - FR-005:同一摊位至多一个待审核申请;保存、提交及审核使用版本/条件更新保护。
 - FR-006(2026-10-09 最新确认):新材料复用商品 `/utils/Upload`,申请保存 `/profile/upload/...` 图片路径数组,不保存图片二进制。用户明确确认身份证等全部材料知道链接即可直接访问。旧私有引用保留原鉴权读取,不迁移文件或回写历史申请。
@@ -62,7 +66,8 @@
 - FR-010:存量摊位必须补齐相同材料并通过审核;不得从旧账号审核状态自动推定通过,不设置自动通过或营业宽限期。
 - FR-011:申请姓名、电话、证件号码和材料不写入公共日志;申请详情及审核仍按权限访问,不向公开摊位/夜市进度查询暴露这些字段。新上传图片链接公开访问是用户确认的例外;商家端、平台端新增文本统一提供四语言。
 - FR-012:DDL、权限菜单及必要数据处理 SQL 仅登记 `updatesql/sql.md`,不直接执行数据库迁移。
-- FR-013(2026-10-09 按用户确认改用通用组件):商家资料页直接使用商品同款 `el-upload`(action 指向 `/utils/Upload`)与 `el-image` 展示,不再维护自建图片组件;平台审核页直接使用共享 `ImagePreview`。保持每类最多 5 张、上传中禁止表单提交、待审核不可编辑;新图片直接按公共路径展示,旧图片仍由页面内 blob 兼容鉴权读取;JPEG/PNG MIME 参数应识别,JSON/HTML 不渲染为图片。
+- FR-013(2026-10-09 按用户确认改用通用组件):商家资料页直接使用商品同款 `el-upload`(action 指向 `/utils/Upload`)与 `el-image` 展示,不再维护自建图片组件;平台审核页直接使用共享 `ImagePreview`。保持每类最多 5 张、上传中禁止表单提交、待审核不可编辑;新图片直接按公共路径展示,旧图片仍由页面内 blob 兼容鉴权读取;JPEG/PNG MIME 参数应识别,JSON/HTML 不渲染为图片。2026-10-16 新增的银行证明和选填证明沿用同样规则。
+- FR-015(2026-10-16):商家 PC 草稿及提交接收表格新增字段;提交时负责人、证件号与照片、摊位照片和银行账户四项必填。食品登录字号与证明、电子邮箱、摊位介绍、营业登记证明、董监事截图及 UBO 声明选填。平台审核详情展示完整申请快照及图片;平台列表、计数和筛选均排除草稿,摊主仍可保存和查看草稿。
 - FR-014:保存/提交只接受系统公共上传目录内实际存在的 JPEG/PNG 图片(不超过 5MB、解码面积不超过 2000 万像素)或本摊位合法旧私有引用。拒绝外部 URL、目录穿越、缺失文件、伪图片和其他格式。通用上传接口调用方式不变,其后端文件名生成处把 `/ \ : * ? " < > | ; # % ,` 与控制字符规范为 `_`(CJK 保留),保证上传产物可直接作为公共路径保存和预览。
 
 ## 存量上线规则(2026-10-09 用户确认)

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

@@ -69,3 +69,11 @@
 - [x] T024 `FileUploadUtils.extractFilename` 规范化 basename:`/ \ : * ? " < > | ; # % ,` 与控制字符替换为 `_`,保留 CJK 等合法字符,空名回退 `file`;新增 `FileUploadUtilsTest`,`身份证#正面.jpg`、`id,front_001.png` 等上传产物可全部通过材料公共路径校验,也消除逗号文件名对 `ImagePreview` 逗号分隔预览的截断。
 - [x] T025 补齐 `messages_th_TH.properties` 的 10 个 `stall.qualification` key(泰文翻译),`BackendI18nBundleContractTest` 通过,后端全量回归不再有既有失败。
 - 两端页面级 Node 测试改为针对页面行为:通用上传卡片 action/数量门禁/类型大小校验/成功追加公共路径、公共引用直显 `VUE_APP_BASE_URL`、历史私有引用 blob 读取与 JSON/HTML 拒绝、待审核只读。
+
+## 2026-10-16 审核资料扩展
+
+- [x] T026 申请 DTO、快照实体、Mapper 和服务增加邮箱、介绍、银行资料及选填证明;食品登录资料改选填,银行四项提交必填,草稿与历史版本规则不变。新增服务/SQL 绑定测试,先确认缺少新字段导致 RED。
+- [x] T027 商家 PC 新增字段及四组证明图片,银行名称复用现有台湾银行字典;平台 PC 详情展示新字段、移除草稿筛选。服务端列表/计数及平台详情排除草稿;两端四语言同步。
+- [x] T028 034 规格、契约、部署说明及 `updatesql/sql.md` 追加 2026-10-16 增量,不执行数据库迁移。
+- [x] T029 后端 JDK 21 全量测试和打包、商家 PC Node 测试及 `build:test`、平台 PC Node 测试及 `build:stage` 均通过;差异与 CRLF 已检查。SQL 未执行,真实环境联调未做;提交状态以 Git 记录为准。
+- [x] T030 补充 `app-frontend-adjustment.md`,供 App 前端对接新资料字段、银行列表、图片上传、草稿及审核状态;App 页面代码未包含在本次三个仓库改动中。

+ 17 - 0
updatesql/sql.md

@@ -2140,3 +2140,20 @@ SELECT 'OMG 退款对账', 'DEFAULT', 'omgPaymentRefundTask.reconcilePending()',
        '0 0/1 * * * ?', '3', '1', '0', 'admin', NOW(), '正式环境信用卡退款;测试环境仅保留未完成意图'
 WHERE NOT EXISTS (SELECT 1 FROM sys_job WHERE invoke_target='omgPaymentRefundTask.reconcilePending()');
 ```
+
+## 2026-10-16 摊位资料审核补充字段(034)
+
+> 仅登记,未执行。先执行本段增量 SQL,再发布读取新字段的后端。历史已通过申请保留资格;旧待审核申请若缺银行四项,不能直接审核通过,须驳回补齐后重新提交。食品业者登录字号及证明改为选填,原列继续使用空字符串和空图片数组表示未提供。
+
+```sql
+ALTER TABLE stall_qualification_application
+  ADD COLUMN email VARCHAR(255) NULL COMMENT '选填电子邮箱' AFTER identity_number,
+  ADD COLUMN stall_introduction VARCHAR(1000) NULL COMMENT '选填摊位介绍' AFTER email,
+  ADD COLUMN bank_account_name VARCHAR(100) NULL COMMENT '银行账户户名' AFTER stall_introduction,
+  ADD COLUMN bank_name VARCHAR(100) NULL COMMENT '银行名称' AFTER bank_account_name,
+  ADD COLUMN bank_account_no VARCHAR(64) NULL COMMENT '银行账号' AFTER bank_name,
+  ADD COLUMN bank_proof_image_urls TEXT NULL COMMENT '银行账户证明图片JSON数组' AFTER stall_image_urls,
+  ADD COLUMN business_registration_image_urls TEXT NULL COMMENT '选填营业登记证明图片JSON数组' AFTER bank_proof_image_urls,
+  ADD COLUMN director_screenshot_image_urls TEXT NULL COMMENT '选填董监事资料截图JSON数组' AFTER business_registration_image_urls,
+  ADD COLUMN ubo_declaration_image_urls TEXT NULL COMMENT '选填实质受益人声明图片JSON数组' AFTER director_screenshot_image_urls;
+```