Explorar el Código

feat(摊位资质): 材料图片改用通用组件并规范上传文件名

qmj hace 1 semana
padre
commit
81436462f9

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

@@ -10,6 +10,7 @@ public class QualificationSaveRequest {
     private String responsibleName;
     private String contactPhone;
     private String identityNumber;
+    /** 新上传传商品图片路径,旧申请可保留本摊位私有附件引用;服务端验证文件。 */
     private List<String> identityImageUrls;
     private List<String> stallImageUrls;
     private String foodRegistrationNumber;

+ 34 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/StallQualificationFileService.java

@@ -14,13 +14,14 @@ 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;
+    private final Path publicRoot;
     public StallQualificationFileService(@Value("${ruoyi.profile}") String profile) {
-        Path publicRoot = Paths.get(profile).toAbsolutePath().normalize();
+        publicRoot = Paths.get(profile).toAbsolutePath().normalize();
         root = publicRoot.resolveSibling("stall-qualification");
         if (root.startsWith(publicRoot)) { throw new IllegalArgumentException("Private qualification directory overlaps profile"); }
     }
@@ -52,7 +53,37 @@ public class StallQualificationFileService {
         } 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 void requireReference(Long storeId, String reference) {
+        // 用户已确认材料按商品图片公开访问;只接受本系统实际上传的图片路径,不接收外部 URL。
+        if (reference != null && reference.startsWith("/profile/upload/")) {
+            requirePublicImage(storeId, reference);
+        } else {
+            checkedPath(storeId, reference);
+        }
+    }
+    private void requirePublicImage(Long storeId, String reference) {
+        if (storeId == null || storeId <= 0 || !reference.matches("/profile/upload/\\d{4}/\\d{2}/\\d{2}/[^/\\\\%?#\\p{Cntrl}]+\\.(?i:jpg|jpeg|png)")) { throw invalid(); }
+        Path path = publicRoot.resolve(reference.substring("/profile/".length())).normalize();
+        try {
+            Path uploadRoot = publicRoot.resolve("upload");
+            // 公共目录也必须防止目录穿越/软链接逃逸,并继续限制材料大小和真实图片格式。
+            if (!path.startsWith(uploadRoot) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)
+                    || !path.toRealPath().startsWith(uploadRoot.toRealPath()) || Files.size(path) > MAX_BYTES) { throw invalid(); }
+            try (ImageInputStream input = ImageIO.createImageInputStream(path.toFile())) {
+                Iterator<ImageReader> readers = ImageIO.getImageReaders(input);
+                if (!readers.hasNext()) { throw invalid(); }
+                ImageReader reader = readers.next();
+                try {
+                    reader.setInput(input);
+                    String format = reader.getFormatName().toLowerCase(java.util.Locale.ROOT);
+                    if (!("jpeg".equals(format) || "jpg".equals(format) || "png".equals(format))
+                            || (long) reader.getWidth(0) * reader.getHeight(0) > 20_000_000L) { throw invalid(); }
+                    reader.read(0);
+                } finally { reader.dispose(); }
+            }
+        } catch (ServiceException exception) { throw exception; }
+        catch (Exception exception) { throw invalid(); }
+    }
     public ByteArrayResource read(Long storeId, String reference) {
         Path path = checkedPath(storeId, reference);
         try { return new ByteArrayResource(Files.readAllBytes(path)); }

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

@@ -16,6 +16,36 @@ import static org.mockito.Mockito.mockStatic;
 class StallQualificationFileServiceTest {
     @TempDir Path directory;
 
+    @Test void productUploadImagePathCanBeSavedAsQualificationMaterial() throws Exception {
+        Path profile = directory.resolve("public");
+        Path image = profile.resolve("upload/2026/10/09/photo_20261009A001.png");
+        java.nio.file.Files.createDirectories(image.getParent());
+        ImageIO.write(new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB), "png", image.toFile());
+        StallQualificationFileService service = new StallQualificationFileService(profile.toString());
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            assertDoesNotThrow(() -> service.requireReference(8L, "/profile/upload/2026/10/09/photo_20261009A001.png"));
+            // 公共图片不再绑定上传者摊位;审核申请的编辑权限仍由业务服务校验。
+            assertDoesNotThrow(() -> service.requireReference(9L, "/profile/upload/2026/10/09/photo_20261009A001.png"));
+        }
+    }
+
+    @Test void publicMaterialRejectsTraversalExternalUrlsMissingAndFakeImages() throws Exception {
+        Path profile = directory.resolve("public");
+        java.nio.file.Files.createDirectories(profile.resolve("upload/2026/10/09"));
+        java.nio.file.Files.writeString(profile.resolve("upload/2026/10/09/fake.png"), "not an image");
+        StallQualificationFileService service = new StallQualificationFileService(profile.toString());
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            for (String reference : new String[] {"/profile/upload/../secret.png", "/profile/upload/%2e%2e/secret.png",
+                    "https://other.test/photo.png", "/profile/upload/2026/10/09/missing.png", "/profile/upload/2026/10/09/fake.png",
+                    "/profile/upload/2026/10/09/fake.png?token=secret", "/profile/upload/2026/10/09/fake.svg"}) {
+                assertThrows(ServiceException.class, () -> service.requireReference(8L, reference));
+            }
+            Path oversize = profile.resolve("upload/2026/10/09/oversize.png");
+            java.nio.file.Files.write(oversize, new byte[5 * 1024 * 1024 + 1]);
+            assertThrows(ServiceException.class, () -> service.requireReference(8L, "/profile/upload/2026/10/09/oversize.png"));
+        }
+    }
+
     @Test void privateFileRoundTripsOutsidePublicProfile() throws Exception {
         StallQualificationFileService service = new StallQualificationFileService(directory.resolve("public").toString());
         ByteArrayOutputStream bytes = new ByteArrayOutputStream();

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

@@ -11,6 +11,7 @@ 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.junit.jupiter.api.io.TempDir;
 import org.mockito.MockedStatic;
 import java.util.List;
 import static org.junit.jupiter.api.Assertions.*;
@@ -19,6 +20,7 @@ import static org.mockito.Mockito.*;
 
 /** 行为断言覆盖跨摊位访问、不可变材料、重复审核和旧有效版本保护。 */
 class StallQualificationServiceTest {
+    @TempDir java.nio.file.Path directory;
     private final StallQualificationMapper mapper = mock(StallQualificationMapper.class);
     private final InfoUserMapper users = mock(InfoUserMapper.class);
     private final PosStoreMapper stores = mock(PosStoreMapper.class);
@@ -74,6 +76,23 @@ class StallQualificationServiceTest {
         assertNotNull(application.getSubmittedAt());
     }
 
+    @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());
+        javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(2, 2, java.awt.image.BufferedImage.TYPE_INT_RGB), "png", image.toFile());
+        StallQualificationService actual = new StallQualificationService(mapper, users, stores,
+                new StallQualificationFileService(directory.toString()));
+        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));
+        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(1, saved.getStatus());
+    }
+
     @Test void staleVersionCannotOverwriteAnotherOwnersDraft() {
         qualification.setVersion(1L);
         try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {

+ 36 - 0
ruoyi-admin/src/test/java/com/ruoyi/common/utils/file/FileUploadUtilsTest.java

@@ -0,0 +1,36 @@
+package com.ruoyi.common.utils.file;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.web.multipart.MultipartFile;
+import java.util.regex.Pattern;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/** 上传文件名规范化保证公共图片路径能通过摊位材料校验,也不会破坏逗号分隔预览。 */
+class FileUploadUtilsTest {
+    private static final Pattern UNSAFE = Pattern.compile("[\\\\:*?\"<>|;#%,\\p{Cntrl}]");
+
+    private MultipartFile file(String originalFilename) {
+        MultipartFile file = mock(MultipartFile.class);
+        when(file.getOriginalFilename()).thenReturn(originalFilename);
+        return file;
+    }
+
+    @Test void extractedFilenamesKeepUrlSafeCharactersOnly() {
+        for (String original : new String[] { "身份证#正面.jpg", "身份证%2e.png", "id,front_001.png",
+                "..\\../evil.png", "###.png", "a;b|c*.png" }) {
+            String fileName = FileUploadUtils.extractFilename(file(original));
+            assertTrue(fileName.matches("\\d{4}/\\d{2}/\\d{2}/[^/]+"), "unexpected layout: " + fileName);
+            String baseName = fileName.substring(fileName.lastIndexOf('/') + 1);
+            assertFalse(UNSAFE.matcher(baseName).find(), original + " -> " + baseName);
+        }
+    }
+
+    @Test void sanitizedBasenamesRemainReadableWithCjkPreserved() {
+        assertEquals("身份证_正面", FileUploadUtils.sanitizeFilename("身份证#正面"));
+        assertEquals("id_front_001", FileUploadUtils.sanitizeFilename("id,front_001"));
+        assertEquals("____", FileUploadUtils.sanitizeFilename("###%"));
+        assertEquals("file", FileUploadUtils.sanitizeFilename(""));
+    }
+}

+ 10 - 1
ruoyi-common/src/main/java/com/ruoyi/common/utils/file/FileUploadUtils.java

@@ -194,7 +194,16 @@ public class FileUploadUtils
     public static final String extractFilename(MultipartFile file)
     {
         return StringUtils.format("{}/{}_{}.{}", DateUtils.datePath(),
-                FilenameUtils.getBaseName(file.getOriginalFilename()), Seq.getId(Seq.uploadSeqType), getExtension(file));
+                sanitizeFilename(FilenameUtils.getBaseName(file.getOriginalFilename())), Seq.getId(Seq.uploadSeqType), getExtension(file));
+    }
+
+    /**
+     * 上传文件名只保留可安全进入 URL 的字符;# % , 等会破坏资源路径校验和逗号分隔预览,Windows 非法字符会导致落盘失败
+     */
+    public static String sanitizeFilename(String baseName)
+    {
+        String sanitized = baseName.replaceAll("[\\\\/:*?\"<>|;#%,\\p{Cntrl}]", "_");
+        return StringUtils.isBlank(sanitized) ? "file" : sanitized;
     }
 
     public static final File getAbsoluteFile(String uploadDir, String fileName) throws IOException

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

@@ -19,6 +19,7 @@ public class StallQualificationApplication {
     private String responsibleName;
     private String contactPhone;
     private String identityNumber;
+    /** 商品上传返回的 /profile/upload 图片路径;兼容历史 storeId/UUID 私有附件引用。 */
     private List<String> identityImageUrls;
     private List<String> stallImageUrls;
     private String foodRegistrationNumber;

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

@@ -5,18 +5,19 @@
 ## 商家 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 或审核字段。附件数组内只允许本摊位私有附件引用。草稿允许不完整,提交必填四组资料。
+- `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/路径穿越/伪图片。草稿允许不完整,提交必填四组资料。
 - `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。
+- 新图片:复用商品 `POST /utils/Upload`,multipart 的 `file`;返回 `{code:200,data:"/profile/upload/YYYY/MM/DD/文件名.png"}`,后端会把文件名中 `/ \ : * ? " < > | ; # % ,` 及控制字符规范为 `_`。商家前端用商品同款 `el-upload` 直传并按 `VUE_APP_BASE_URL + data` 展示,平台直接用共享 `ImagePreview` 解析路径。无需额外调用审核文件接口,图片 GET 无需登录,用户已确认包括证件在内的链接公开访问。
+- `POST /stall/qualification/files`:保留兼容,multipart file 返回旧私有引用 `storeId/uuid.jpg` 或 `.png`;新版 PC 不再调用。
+- `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。
+- `GET /system/stallQualification/files/{storeId}/{fileName}`:仅兼容历史私有附件,query 权限,图片二进制且 Cache-Control=no-store;新公共路径直接使用商品图片预览,不请求此接口。
 
 写入/审核失败使用项目国际化业务错误;不记录包含材料的请求/响应业务日志。JSON/绑定参数错误仅返回通用本地化参数错误,不把原始证件值、附件引用或解析异常交给全局日志。材料 Mapper WARN、Druid 材料 SQL 参数在慢日志及监控缓存中脱敏。
 分页列表默认10、最多100条。主表/申请 version 不匹配时返回冲突业务错误,前端提示重新加载,不自动重试覆盖他人资料。

+ 1 - 1
specs/034-stall-qualification/data-model.md

@@ -18,7 +18,7 @@
 - `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。
+- `identity_image_urls`、`stall_image_urls`、`food_registration_image_urls`:商品图片路径数组(`/profile/upload/YYYY/MM/DD/文件名.jpg或png`)序列化存储,不保存文件二进制;新图片按商品方式公开访问。兼容历史 `storeId/UUID.jpg或png` 私有引用,不回写旧申请。字段、Mapper、SQL 均不变,无新数据迁移。
 - 第一版只保存统一表单的四组资料,不建立商业/税籍登记、保险等补充证明字段。
 - `stall_name_snapshot`、`stall_address_snapshot`、`night_market_id_snapshot`:提交时由服务端读摊位数据固化,用于审核同一版本的实际内容。
 - `submitted_by`、`submitted_at`。

+ 14 - 4
specs/034-stall-qualification/plan.md

@@ -3,11 +3,11 @@
 **工作分支**:`test-202609v2`,不创建新分支。
 **日期**:2026-10-09
 **规格**:[spec.md](spec.md)
-**进度**:已验证(本地定向检查);四组资料、存量营业门禁及两个 PC 已实现,部署/真实联调尚未执行。见 `quickstart.md`。
+**进度**:已验证(后端定向与全量 952/952、两端 Node 测试及构建);四组资料、存量营业门禁及两个 PC 已实现,图片上传改用通用组件并在后端规范化文件名,泰文语言包补齐,均已提交/推送,部署/真实联调尚未执行。见 `quickstart.md`、`tasks.md`。
 
 ## 技术约束
 
-沿用 Java/JDK 21、Spring Boot、MyBatis 和 MySQL;模块依赖保持 `ruoyi-admin -> ruoyi-system`。商家 PC 和平台沿用 Vue 2、现有请求封装;敏感材料使用专用私有上传,不复用公开上传。实体、Mapper/XML 放 `ruoyi-system`,业务编排和明确请求 DTO 放 `ruoyi-admin`;登录身份采用现有认证机制,禁止信任客户端提交的操作人或归属 ID。
+沿用 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、用户确认范围和本规格为约束,不修改全局模板。
 
@@ -34,7 +34,7 @@
 - 前端:表单统一、进度/驳回原因/历史回显、四语言键一致和 Vue 编译。
 - 实际数据库并发、文件访问权限与跨端联调如未执行,交付时明确列出,不以模拟测试代替真实验证。
 
-## 本轮执行批次(2026-10-09)
+## 初始执行批次(2026-10-09,历史记录;图片上传以末尾最新增量为准)
 
 沿用当前三个仓库的 `test-202609v2`,直接在本会话实施,不新建分支/工作树、不自动提交。按 executing-plans 和 test-driven-development 执行,进度写入本目录 tasks.md,而不是另建技能工作目录。
 
@@ -64,8 +64,18 @@
 - 当前 MyBatis-Plus 3.5.10 的 `saveOrUpdate` 直接调用 Mapper;单独覆盖该入口,不依赖 `save/updateById` 间接拦截。PosStore XML insert/update 补实际 `state` 写入,闭店完整表单编辑不能被误判为开店。
 - 运行定向 Maven 测试及受影响模块编译,检查 SQL/XML、前端编译、差异、换行和全部四语言 key;记录尚未执行的部署/浏览器联调。
 
-## 图片组件增量(2026-10-09)
+## 图片组件增量(2026-10-09,历史记录;私有方案已被下一节覆盖)
 
 用户确认摊位上传与门店管理统一、平台材料展示与商品管理统一。商家现有门店页直接使用 `el-upload.avatar-uploader`,资料组件复用该方框加号交互与 100px 缩略图,保留多图、删除、上传互斥和私有 multipart 接口。平台 `PrivateImages` 改用现有 `ImagePreview`;共享组件只补 `blob:` 原样解析和选填 `previewSrcList`,未传时商品等原调用保持原行为,同组私有图片可放大切换。
 
 两端私有图片加载比较去掉参数的媒体类型,只接受 JPEG/PNG,不放宽为任意二进制,也不把令牌放到图片地址中。先用真实 Vue 组件回归复现 blob 前缀错误和参数化 MIME 被拒绝,再验证修正、只读/数量限制及原商品地址兼容。无后端接口、数据字段或 SQL 变更。截图中的部署失败仍须按实际请求状态、响应类型、权限和私有目录确认,不把本地兼容测试等同于线上根因已确认。
+
+## 商品图片上传增量(2026-10-09,最新确认)
+
+用户明确要求直接按商品上传图片方式实现,并在告知身份证等图片将知道链接即可访问后确认。此确认覆盖前面禁止公共上传的旧方案,不改变申请详情、提交、审核的身份/权限保护。
+
+1. 商家 `uploadQualificationImage` 调用商品现用的 `VUE_APP_BACKEND_URL + /utils/Upload`,multipart `file`,保存响应 `data` 的 `/profile/upload/...` 路径;不新增上传接口或配置。继续保留每类 5 张、每张 5MB、JPEG/PNG、上传忙锁及只读限制。
+2. `StallQualificationFileService.requireReference` 接收实际存在的公共上传图片,验证目录边界、真实 JPEG/PNG、大小及解码面积;历史引用仍要求属于当前摊位。实体/DTO 只补字段注释,Mapper、表结构、状态和并发校验不变。
+3. 商家将公共路径按商品后端域名展示,平台直接交给商品 `ImagePreview`;只有历史私有引用请求原文件接口,释放资源时只 revoke 临时 blob。
+4. 不迁移历史文件、不回写已提交申请;已有私有图片若服务器缺失或原权限/代理异常,仍需单独处理,本增量不宣称修复旧文件部署问题。原私有上传/读取端点仅保留兼容,新 PC 不再使用私有上传。
+5. 回归覆盖真实公共图片验证、保存/提交快照路径、新公共与旧私有混合预览、商品上传请求及忙锁;运行后端定向/全量测试、模块构建和两端全部 Node 用例/构建,检查 CRLF 和变更范围。数据库、App、部署与真浏览器联调不执行。

+ 16 - 6
specs/034-stall-qualification/quickstart.md

@@ -1,13 +1,13 @@
 # 034 摊位审核部署与验收
 
-日期:2026-10-09。后端、商家 PC、平台 PC 均在 `test-202609v2` 原分支实施,未新建分支;按用户要求在三个仓库原分支提交和推送,具体 SHA 与远端结果以 Git 记录和最终交付回复为准;App 未修改。
+日期: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`。
+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 参数的额外日志过滤器。
 
@@ -43,16 +43,26 @@ Druid 配置测试构造未初始化/未连接的数据源,并确认 proxyFilt
 
 - 真实 MySQL 建表/菜单 SQL、主表首次并发初始化、两摊主同时提交、两管理员同时审核、事务失败回滚和待审核唯一约束。
 - 浏览器真实登录、上传/预览、草稿回显、驳回重提、审核通过后手动开店、存量未认证禁止新单、旧订单继续履约。
-- 生产/测试实际文件目录持久化、多实例共享、nginx 无公共访问、停用账号和越权材料访问。
+- 生产/测试文件目录持久化、多实例共享、新公共图片可访问、历史私有附件仍鉴权读取;停用账号和跨摊位申请访问拒绝。
 - 实际 Druid 慢查询日志/监控页面脱敏与环境覆盖配置,私有证件不出现在应用、代理或审计日志。
-- 完整项目全量回归未运行,本轮只运行上述定向 Java 用例和已有 PC Node 测试。
+- 初始功能只运行上述定向 Java 用例和 PC Node 测试;商品上传增量及后续收口已运行后端全量 952 项全部通过(含补齐泰文语言包后的 i18n 契约测试),详见 tasks.md T022–T025。
 
 不得将本页的模拟/配置测试视为上述真实联调的替代。
 
-## 图片组件调整及故障核对(2026-10-09)
+## 图片组件调整及故障核对(2026-10-09,历史私有方案记录)
 
 - 商家上传改为门店管理同款方框加号,每类 5 张,待审核/只读无上传和删除;仍使用私有文件接口,不能改用 `/utils/Upload`。平台审核材料使用商品管理的 `ImagePreview`,保留同组放大切换,私有 blob 地址不能拼 `/prod-api`。
 - 图片 Content-Type 带 charset 等参数时,前端按媒体类型识别 JPEG/PNG;JSON/HTML 错误响应仍显示原占位,不渲染为证件。后端接口及 SQL 无变化。
 - 若部署后仍看不到图片,在浏览器 Network 核对私有文件请求状态和 Content-Type:401/403 检查平台登录及 query 权限;404 检查代理路由、服务实例及私有目录共享/持久化;200 但返回 HTML/JSON 检查代理回退页或业务错误。平台详情能打开不代表图片文件一定在当前服务实例上。
 - 需要同时验证平台和商家读取同一已上传文件。不要粘贴带令牌的请求头、真实证件内容或完整材料引用到公共日志;排障只记录必要的状态和类型。截图本身无法确定线上请求失败原因,本轮本地组件回归不替代真实浏览器和服务器联调。
-- 本次自动验证:两个 PC 全部 Node 测试分别为平台 20/20、商家 9/9;商家 `npm run build:test`、平台 `vue-cli-service build --mode staging`(仅本命令设置 OpenSSL 兼容选项)成功。构建保留既有商家 7 项缺 key 警告、平台 2 个 `.vue~` 文件与包体积提示。`PrivateImages.vue` ESLint 通过;共享 `ImagePreview` 保持旧风格,30 项 ESLint 错误/1 项警告与修改前 HEAD 基线数量一致,未借本任务整体格式化。
+- 本次自动验证:两个 PC 全部 Node 测试分别为平台 20/20、商家 11/11;商家 `npm run build:test`、平台 `npm run build:stage`(仅本命令设置 OpenSSL 兼容选项)成功。构建保留既有商家 7 项缺 key 警告、平台 2 个 `.vue~` 文件与包体积提示。共享 `ImagePreview` 保持旧风格,未借本任务整体格式化。
+
+## 商品上传方式(2026-10-09,当前方案)
+
+- 用户确认包括身份证在内的新材料使用商品公共图片方式,本节覆盖前面的旧私有上传限制。新上传复用 `/utils/Upload`,保存其 `data` 返回的 `/profile/upload/YYYY/MM/DD/文件名.jpg或png`;字段名及数组结构不变。
+- 按用户确认,上传/展示组件换成其他功能通用的实现:商家页直接用商品同款 `el-upload`(action 为 `VUE_APP_BASE_URL + '/utils/Upload'`)和 `el-image`,平台审核页直接用共享 `ImagePreview`;`StallQualificationImages.vue`、`PrivateImages.vue` 已删除。
+- `FileUploadUtils` 生成存储文件名时把 `/ \ : * ? " < > | ; # % ,` 与控制字符替换为 `_`(CJK 等保留):`身份证#正面.jpg`、含逗号文件名上传后仍是合法公共路径,可通过材料保存校验,也不会破坏逗号分隔预览。商品等其他使用该工具的上传同步受益,已存旧文件不受影响。
+- 商家按 `VUE_APP_BASE_URL + 图片路径` 显示(与商品页一致);平台路径按现有 `VUE_APP_BASE_API` 解析。新图片不会再请求 `/system/stallQualification/files/...`。不能只发布 PC,必须同时发布后端公共路径保存校验。
+- 申请详情、审核、营业门禁及历史版本保护不变。公共图片链接本身无需登录;服务端保存时仍确认是系统实际存在的 JPEG/PNG、最多5MB、2000万像素,拒绝外部 URL 和非法路径。
+- 历史 `storeId/UUID.jpg或png` 引用不迁移、不回写,继续请求旧鉴权接口(两端页面内保留 blob 兼容读取)。此次更换上传方式不恢复服务器缺失的旧私有文件,也不证明原截图的线上根因已排除;需保留旧目录或由摊主在可编辑的新申请中重新上传。
+- 无新增字段、DDL、数据迁移或 App 修改;不自动部署。当前验证结果见 tasks.md 的 T023–T025,生产/测试真实浏览器上传与图片 GET、目录持久化仍待部署后联调。

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

@@ -2,7 +2,7 @@
 
 **工作分支**:`test-202609v2`,沿用现有分支,不创建新分支。
 **创建日期**:2026-10-09
-**进度**:已验证(本地定向测试、构建及静态审查);按用户要求在三个仓库原分支提交和推送,结果以 Git 记录为准。SQL 未执行,未部署联调。见 `quickstart.md`。
+**进度**:已验证(本地定向测试、构建及静态审查;全量回归保留1项既有泰文失败);最新商品图片上传增量尚未提交/推送。此前提交以 Git 记录为准,SQL 未执行,未部署联调。见 `tasks.md` 和 `quickstart.md`。
 **输入**:夜市主创建摊位及摊主账号;摊主登录后自行提交认证资料,由平台审核。认证归属摊位,独立于登录账号;使用认证主表和申请记录表。所有摊位使用完全相同的资料项目,不区分个人经营或公司经营。
 
 ## 已确认范围与建议口径
@@ -21,10 +21,10 @@
 | 负责人姓名 `responsibleName` | 申请资料 | 必填 |
 | 联系手机号 `contactPhone` | 申请资料,与账号登录手机号分别管理 | 必填 |
 | 身份证/护照号码 `identityNumber` | 申请资料 | 必填 |
-| 身份证/护照照片 `identityImageUrls` | 私有附件引用数组 | 必填,证件照片应完整清晰 |
-| 摊位现场照片 `stallImageUrls` | 私有附件引用数组 | 必填 |
+| 身份证/护照照片 `identityImageUrls` | 商品上传图片路径数组,兼容旧私有引用 | 必填,证件照片应完整清晰 |
+| 摊位现场照片 `stallImageUrls` | 商品上传图片路径数组,兼容旧私有引用 | 必填 |
 | 食品业者登录字号 `foodRegistrationNumber` | 申请资料 | 统一作为平台入驻必填项 |
-| 食品业者登录证明 `foodRegistrationImageUrls` | 私有附件引用数组 | 统一作为平台入驻必填项 |
+| 食品业者登录证明 `foodRegistrationImageUrls` | 商品上传图片路径数组,兼容旧私有引用 | 统一作为平台入驻必填项 |
 
 这份表格只定义平台入驻审核所收集的四组资料:负责人姓名/电话、身份证明、摊位信息/照片、食品业者登录字号/证明。不收集商业/税籍登记、保险等补充证明,不代表免除经营者依法应履行的义务;食品登录必填属于平台准入标准,不宣称台湾所有摊贩依法都必须具备。首期不新增董监事截图、UBO 声明等公司专属项目。银行卡、收款账户及支付凭证继续由现有支付配置维护,不在认证模块重复建立收款配置。
 
@@ -40,7 +40,7 @@
 
 有审核权限的平台管理员查看所属夜市、摊位、申请材料及提交人,执行通过或驳回;驳回原因必填。审核同一版本只允许成功一次。驳回后以新版本重新填写,不修改既有审核记录。
 
-验收:无审核权限拒绝访问材料或执行审核;通过后主表指向该申请;驳回后保留材料及原因;重复或并发审核不得产生冲突结果。
+验收:无审核权限拒绝访问申请详情或执行审核;通过后主表指向该申请;驳回后保留材料及原因;重复或并发审核不得产生冲突结果。按用户最新确认,新图片链接本身公开访问,不受审核详情权限保护。
 
 ### US3:查看认证进度与有效资料(P2)
 
@@ -55,14 +55,15 @@
 - FR-003:草稿、待审核、通过、驳回必须清晰展示;申请状态不能与当前有效资质混为一谈。
 - FR-004:提交后材料不可变;通过、驳回均保存操作人和时间;驳回保存原因。
 - FR-005:同一摊位至多一个待审核申请;保存、提交及审核使用版本/条件更新保护。
-- FR-006:文件使用专用私有上传和鉴权读取接口,申请保存附件引用,不在数据库保存图片二进制;证件不使用公共上传目录。
+- FR-006(2026-10-09 最新确认):新材料复用商品 `/utils/Upload`,申请保存 `/profile/upload/...` 图片路径数组,不保存图片二进制。用户明确确认身份证等全部材料知道链接即可直接访问。旧私有引用保留原鉴权读取,不迁移文件或回写历史申请。
 - FR-007:后端校验类型 4 账号真实绑定的 `storeId` 和账号/摊位有效状态;平台按权限操作。
 - FR-008:旧 `InfoUser.auditStatus` 不能作为摊位资质审核通过的依据,普通商家及骑手原审核功能不受此模块替换。
 - FR-009:新摊位和存量摊位统一在材料审核通过后才允许正式营业和承接新订单;已存在订单的履约不能被认证状态变更阻断。
 - FR-010:存量摊位必须补齐相同材料并通过审核;不得从旧账号审核状态自动推定通过,不设置自动通过或营业宽限期。
-- FR-011:材料不写入公共日志或公开接口;商家端、平台端新增文本统一提供四语言。
+- FR-011:申请姓名、电话、证件号码和材料不写入公共日志;申请详情及审核仍按权限访问,不向公开摊位/夜市进度查询暴露这些字段。新上传图片链接公开访问是用户确认的例外;商家端、平台端新增文本统一提供四语言。
 - FR-012:DDL、权限菜单及必要数据处理 SQL 仅登记 `updatesql/sql.md`,不直接执行数据库迁移。
-- FR-013(2026-10-09 图片组件调整):商家资料图片上传采用门店管理的方框加号样式,保持每类最多 5 张、上传中禁止该组件重复上传及表单提交、待审核不可编辑;平台材料预览复用商品管理的 `ImagePreview`,支持缩略图、放大及同组切换。私有上传/鉴权读取不改成公开地址;合法 JPEG/PNG 的 Content-Type 附加参数不应导致图片被丢弃,JSON/HTML 错误响应仍不得渲染为证件。
+- FR-013(2026-10-09 按用户确认改用通用组件):商家资料页直接使用商品同款 `el-upload`(action 指向 `/utils/Upload`)与 `el-image` 展示,不再维护自建图片组件;平台审核页直接使用共享 `ImagePreview`。保持每类最多 5 张、上传中禁止表单提交、待审核不可编辑;新图片直接按公共路径展示,旧图片仍由页面内 blob 兼容鉴权读取;JPEG/PNG MIME 参数应识别,JSON/HTML 不渲染为图片。
+- FR-014:保存/提交只接受系统公共上传目录内实际存在的 JPEG/PNG 图片(不超过 5MB、解码面积不超过 2000 万像素)或本摊位合法旧私有引用。拒绝外部 URL、目录穿越、缺失文件、伪图片和其他格式。通用上传接口调用方式不变,其后端文件名生成处把 `/ \ : * ? " < > | ; # % ,` 与控制字符规范为 `_`(CJK 保留),保证上传产物可直接作为公共路径保存和预览。
 
 ## 存量上线规则(2026-10-09 用户确认)
 

+ 29 - 1
specs/034-stall-qualification/tasks.md

@@ -2,7 +2,7 @@
 
 **日期**:2026-10-09
 **分支**:`test-202609v2`,不创建新分支。
-**总进度**:已验证(本地定向测试、后端模块打包、两个 PC 构建及静态审查)。按用户要求在三个仓库原分支提交和推送,结果以 Git 记录为准;未执行 SQL、部署联调,真实环境验收见 `quickstart.md`。
+**总进度**:已验证(后端定向测试与全量回归、两端 Node 测试及构建、静态审查)。初始功能与商品图片增量、通用组件替换及文件名规范、泰文语言包补齐均已提交/推送,SHA 以 Git 记录为准;未执行 SQL、部署联调,真实环境验收见 `quickstart.md`。
 
 ## Specify
 
@@ -41,3 +41,31 @@
 - [x] T015 商家上传交互对齐门店管理,平台复用商品 `ImagePreview` 并保留同组预览;私有访问权限、审核状态及资料保存接口不变。
 - [x] T016 两端图片加载支持 MIME 参数,仍拒绝 JSON/HTML;更新现有规格、计划及部署诊断说明。
 - [x] T017 平台全部 Node 测试 20/20、商家 9/9 通过,两端构建成功(保留原有警告);审核图片组件 ESLint 通过,共享 ImagePreview 既有 30 项错误/1 项警告与 HEAD 基线一致,不扩展无关格式修改。核对本次改动与 CRLF,无后端代码/SQL 变更;实际浏览器、截图对应请求及服务器文件部署未联调,仍待验收。
+
+## 商品图片上传增量(2026-10-09,最新用户确认)
+
+**本增量进度:已验证(本地定向测试、构建;全量回归1项既有失败)**。未提交/推送。用户明确确认全部审核图片(含身份证)采用商品公共上传/访问,覆盖先前私有上传限制;申请详情、审核、营业门禁不变。
+
+- [x] T018 先补失败回归:公共材料保存被旧校验拒绝、商家上传仍调用旧接口、两端公共图片仍请求旧私有接口;实际运行观察失败,再实现。
+- [x] T019 商家复用 `/utils/Upload`,保存 `/profile/upload/...` 图片路径;后端验证实际图片文件,拒绝目录穿越、外部 URL、缺失/伪造/过大图片,保留旧引用归属检查。
+- [x] T020 两端直接预览新公共图片,旧材料鉴权读取兼容;保留上传数量、忙锁、只读和同组预览。
+- [x] T021 更新规格、计划、数据模型、接口及部署说明,补注释,明确公开链接风险、旧材料不迁移、无 SQL/新字段/App 改动。
+- [x] T022 完成定向/全量测试、模块打包、两个 PC 构建及 CRLF/差异核对,记录未验证项。不自动提交/推送。
+
+### 本增量实际验证结果
+
+- RED:后端公共路径保存回归1项失败;商家公共预览/商品上传接口回归2项失败;平台公共预览回归1项失败。GREEN:后端 `mvn -pl ruoyi-admin -am test -Dtest=StallQualification*Test -Dsurefire.failIfNoSpecifiedTests=false` 31/31 通过。
+- 两个 PC `node --test tests/*.test.cjs`:商家11/11、平台21/21通过。覆盖公共路径直接预览、旧私有读取、商品 multipart 请求、只读/数量/忙锁、同组预览及 MIME 错误处理。
+- JDK21 后端全模块 `mvn -pl ruoyi-admin -am test`:950项,949通过、1失败、0错误。唯一失败为已有 `BackendI18nBundleContractTest.everyLocalizedBundleHasTheSameNonBlankKeysAndPlaceholders`,`messages_th_TH.properties` 缺10个 `stall.qualification` key;本轮不改语言包或该测试,不把全量描述为通过。
+- `mvn -pl ruoyi-admin -am package -DskipTests` 成功;商家 `vue-cli-service build --mode test` 和平台 `vue-cli-service build --mode staging`(命令内设置 OpenSSL兼容选项)成功。保留既有商家7项缺key提示、平台2个 `.vue~` 文件及包体积提示。
+- 平台修改组件 ESLint `--no-ignore` 通过;三个仓库 `git diff --check` 通过,编辑文件保留 UTF-8/CRLF,没有改动通用上传实现、依赖/锁文件、i18n、SQL 或 App。
+- 未验证:部署、真实登录浏览器上传与图片访问、旧文件服务器缺失/权限/代理故障、真实数据库与多实例目录共享;不上传凭证、不执行数据库或文件迁移。
+
+## 通用图片组件与文件名规范增量(2026-10-09,用户确认后收口)
+
+**本增量进度:已验证(后端定向 35/35、全量 952/952、商家 Node 11/11、平台 Node 20/20、商家 `build:test` 与平台 `build:stage` 构建成功)**。用户确认上传/展示图片组件换成其他功能通用的实现,后端公共上传统一规范化文件名。
+
+- [x] T023 删除两端自建图片组件:商家 `StallQualification.vue` 直接用商品同款 `el-upload`(action 指向 `seruer + '/utils/Upload'`,`seruer = VUE_APP_BASE_URL`)与 `el-image` 展示;平台审核页直接用共享 `ImagePreview` 组件。旧私有引用仍在页面内小段 blob 兼容读取,接口不变;`StallQualificationImages.vue`、`PrivateImages.vue` 及商家自建 `uploadQualificationImage` 封装删除。
+- [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 拒绝、待审核只读。