From 934741090903f4808e727838cf3a8841d3c58562 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 29 Oct 2026 23:50:19 +0800 Subject: [PATCH] 111 --- docs/file-access/README.md | 10 +- docs/file-access/config.example.yml | 5 +- docs/file-access/verification.md | 10 ++ .../core/file/FileAccessProperties.java | 3 +- .../admin/service/file/FileAccessGuard.java | 6 +- .../service/impl/SysUserServiceImpl.java | 6 +- .../service/file/FileAccessMobileTest.java | 82 +++++++++- .../impl/SysUserPageFaceQueryTest.java | 142 ++++++++++++++++++ 8 files changed, 257 insertions(+), 7 deletions(-) create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/SysUserPageFaceQueryTest.java diff --git a/docs/file-access/README.md b/docs/file-access/README.md index b53481ba3..029f5168e 100644 --- a/docs/file-access/README.md +++ b/docs/file-access/README.md @@ -76,14 +76,22 @@ Base64URL无填充编码的UTF-8文件名 file-access: public-image-referer-prefixes: - https://www.pjzhhq.cn/mobile/ + - https://yyfb.idw.jinhua.gov.cn:6443/ ``` -只对 GET/HEAD 图片读取生效:请求带有单个 `Referer`,其协议、主机、端口与前缀匹配,且路径位于 `/mobile/` 下时,JPG/JPEG/PNG/GIF/WebP/BMP/SVG/ICO 可以不带 appId 或签名读取。页面 URL 的 `#/` 不会随 HTTP Referer 发送。来源命中的图片请求整体免签,因此旧页面携带的过期签名也不阻止加载。重复关键参数、非法文件名和浏览器页面导航仍优先拒绝;PDF、APK、日志等文件及上传接口不享受此例外。 +只对 GET/HEAD 图片读取生效:请求带有单个 `Referer`,其协议、主机、有效端口与配置匹配时,按以下两种范围判断: + +- 带目录的配置(如 `/mobile/`)仍要求 Referer 路径位于该目录下,目录必须以 `/` 结尾。 +- 根来源配置(如 `https://yyfb.idw.jinhua.gov.cn:6443/`,末尾 `/` 可省略)允许该来源下所有页面,包括浏览器跨域策略只发送根来源的请求。端口必须匹配;未写端口的 HTTPS 表示 443,不会匹配 6443。根来源不能再限制到某个应用目录。 + +命中后,JPG/JPEG/PNG/GIF/WebP/BMP/SVG/ICO 可以不带 appId 或签名读取。页面 URL 的 `#/` 不会随 HTTP Referer 发送,也不能写进配置。跨域策略如果省略路径,单纯配置长目录不会自动退回根来源匹配。来源命中的图片请求整体免签,因此旧页面携带的过期签名也不阻止加载。重复关键参数、非法文件名和浏览器页面导航仍优先拒绝;PDF、APK、日志等文件及上传接口不享受此例外。 这是独立的来源白名单,保留或省略 `clients.h5` 都可使用;不能仅根据 `appId=h5` 放行。新版本允许省略整个 H5 客户端配置,但空密钥、错误密钥配置仍报错,其他五类客户端的必要配置保持严格检查。若删除共享配置中的 H5 条目,所有加载旧版公共配置类的服务也需要同步更新;保留现有 H5 配置同样可以启用来源兼容。 配置默认空列表;显式清空列表即可恢复图片签名要求。必须部署新 UPMS 和对应的公共依赖后再启用配置,不能用旧后端只删除 H5 密钥。Referer 必须由浏览器和代理保留;没有 Referer 的匿名请求仍返回 403。Referer 可被脚本模拟,这是一层兼容性的来源检查。成功响应私有缓存最多 5 分钟,`Vary` 额外区分 Referer,代理不得忽略该差异复用放行响应。 +根来源支持需要新版 `pjcloud-common-core` 和 UPMS 一起构建发布。建议将这份图片来源白名单放在 UPMS 自己的配置中;若通过共享配置下发给其他启用文件访问配置的服务,这些服务也必须先更新公共依赖,旧版校验类会拒绝根来源。此改动不要求更新 H5 或更换任何密钥。 + ## 上传身份与文件类型 现有登录鉴权及 `@Inner` 内部调用限制保留。每次 POST multipart 上传只允许一个名为 `file` 的文件部分,通过下列请求头传递身份,不把上传凭据放进 URL 或业务字段: diff --git a/docs/file-access/config.example.yml b/docs/file-access/config.example.yml index 9fdeded4e..16f2ca259 100644 --- a/docs/file-access/config.example.yml +++ b/docs/file-access/config.example.yml @@ -8,10 +8,13 @@ file-access: public-base-url: https://files.example.com/api allowed-origins: - https://legacy-files.example.com - # Optional: old H5 needs no update; only image reads from this Referer prefix bypass signatures. + # Optional: old H5 needs no update; only GET/HEAD image reads bypass signatures. # Remove this list or set [] to keep all browser sources subject to signing. public-image-referer-prefixes: - https://www.pjzhhq.cn/mobile/ + # Root origin allows every page on this exact scheme/host/port (trailing slash optional). + # Use only when cross-origin browser requests omit the Referer path; requires updated common-core/UPMS. + # - https://yyfb.idw.jinhua.gov.cn:6443/ legacy-dart: enabled: true extensions: [jpg, jpeg, png, gif, webp, bmp, svg, ico] diff --git a/docs/file-access/verification.md b/docs/file-access/verification.md index 960949491..da1465b4a 100644 --- a/docs/file-access/verification.md +++ b/docs/file-access/verification.md @@ -140,3 +140,13 @@ Java 定向测试及各端构建命令见 [接入与发布说明](README.md)。 - 实际检查线上 `/mobile/` 返回 200,未发现禁用 Referer 的响应头或 HTML meta;这不代替后端部署后的真机验收。 精确前缀已加入本机被忽略的生成配置,其余配置和密钥保留。生产 Nacos 和线上服务未修改;需要部署新 UPMS/公共依赖并加载来源白名单,H5 无需重新发布。Referer 可被脚本模拟,此功能为应用级来源兼容。 + +## 政务 H5 根来源兼容补充 + +2026-10-29 实际检查政务 H5 的图片请求:页面位于 `https://yyfb.idw.jinhua.gov.cn:6443/web/mgop/gov-open/zj/2002546649/reserved/index.html#/`,但浏览器使用 `strict-origin-when-cross-origin`,发往文件中心的 Referer 仅为 `https://yyfb.idw.jinhua.gov.cn:6443/`。因此原目录前缀无法命中。 + +现有 `public-image-referer-prefixes` 同时支持目录前缀和显式根来源;根来源末尾 `/` 可省略,允许同协议、同主机、同有效端口下所有页面的图片请求。目录配置仍严格匹配目录,不会因 Referer 省略路径而自动扩大范围。仅允许 GET/HEAD 图片、阻止浏览器导航、上传鉴权、私有缓存和 Vary 规则保持不变。 + +- 执行 `mvn -pl pjcloud-upms/pjcloud-upms-biz -am test -Dtest=FileAccessTest,FileAccessMobileTest,FileUploadGuardTest -Dsurefire.failIfNoSpecifiedTests=false`:23 项通过,0 失败、错误或跳过,Maven 构建成功。 +- 新增测试覆盖实际根 Referer、完整页面 Referer、无末尾斜杠的配置、默认/自定义端口、域名伪造、错误协议、重复/缺失 Referer、目录范围不扩大、关闭白名单、导航及非图片拒绝、上传仍需鉴权、GET/HEAD 请求到存储的边界。 +- 本机验证日志为 `/tmp/pj-file-access-origin-tests.log`。未修改线上配置或发布服务,线上恢复情况需在新版 UPMS 和对应公共依赖发布后确认。若根来源配置共享给其他启用文件配置的服务,也需先更新这些服务的公共依赖。 diff --git a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessProperties.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessProperties.java index e7da1e800..f960ac51b 100644 --- a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessProperties.java +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessProperties.java @@ -16,6 +16,7 @@ public class FileAccessProperties implements InitializingBean { private boolean blockBrowserNavigation = true; private String publicBaseUrl = ""; private List allowedOrigins = new ArrayList<>(); + /** An empty or root URI path allows the whole origin; other paths must end with '/'. */ private List publicImageRefererPrefixes = new ArrayList<>(); private Map clients = new LinkedHashMap<>(); private LegacyDart legacyDart = new LegacyDart(); @@ -80,7 +81,7 @@ public class FileAccessProperties implements InitializingBean { String path = uri.getRawPath(); if (uri.getHost() == null || !("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme())) || uri.getUserInfo() != null || uri.getRawQuery() != null || uri.getRawFragment() != null - || path == null || path.length() <= 1 || !path.endsWith("/") || path.contains("%") + || path == null || (!path.isEmpty() && !path.endsWith("/")) || path.contains("%") || !path.equals(uri.normalize().getRawPath())) throw new IllegalArgumentException(); } catch (RuntimeException e) { throw new IllegalStateException("Invalid public image Referer prefix"); } } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileAccessGuard.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileAccessGuard.java index 6d0225463..b9aea2874 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileAccessGuard.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileAccessGuard.java @@ -99,9 +99,13 @@ public class FileAccessGuard { String path = new URI(null, null, referer.getPath(), null).normalize().getPath(); for (String configured : properties.getPublicImageRefererPrefixes()) { URI prefix = URI.create(configured); + // Cross-origin browser requests can retain only the origin in Referer. + // Only an explicitly configured root allows every path on that origin. + boolean wholeOrigin = prefix.getRawPath().isEmpty() || "/".equals(prefix.getRawPath()); if (prefix.getScheme().equalsIgnoreCase(referer.getScheme()) && prefix.getHost().equalsIgnoreCase(referer.getHost()) && port(prefix) == port(referer) - && referer.getRawPath().startsWith(prefix.getRawPath()) && path.startsWith(prefix.getPath())) return true; + && (wholeOrigin || (referer.getRawPath().startsWith(prefix.getRawPath()) + && path.startsWith(prefix.getPath())))) return true; } } catch (IllegalArgumentException | URISyntaxException ignored) { } return false; diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java index 277a3d91b..e2d4942e9 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java @@ -261,9 +261,10 @@ public class SysUserServiceImpl extends ServiceImpl impl dataScope.setScopeUserName(null); IPage list = baseMapper.getUserVosPage(page, userDTO, dataScope); if (ObjectUtil.isNotEmpty(list.getRecords())) { - List userIds = list.getRecords().stream().map(UserVO::getUserId).distinct().toList(); + List userIds = list.getRecords().stream().map(UserVO::getUserId).filter(Objects::nonNull).distinct().toList(); if (ObjectUtil.isNotEmpty(userIds)) { SysUserFaceDto faceDto = new SysUserFaceDto(); + faceDto.setUserIds(userIds); List faceList = sysUserFaceMapper.groupConcatFaces(faceDto); DataUtil.convertFacesList(list.getRecords(), CollectionUtils.convertMap(faceList, SysUserFaceVo::getUserId)); } @@ -292,9 +293,10 @@ public class SysUserServiceImpl extends ServiceImpl impl public IPage innerPage(Page page, UserDTO userDTO) { IPage list = baseMapper.getUserVosPage(page, userDTO, null); if (ObjectUtil.isNotEmpty(list.getRecords())) { - List userIds = list.getRecords().stream().map(UserVO::getUserId).distinct().toList(); + List userIds = list.getRecords().stream().map(UserVO::getUserId).filter(Objects::nonNull).distinct().toList(); if (ObjectUtil.isNotEmpty(userIds)) { SysUserFaceDto faceDto = new SysUserFaceDto(); + faceDto.setUserIds(userIds); List faceList = sysUserFaceMapper.groupConcatFaces(faceDto); DataUtil.convertFacesList(list.getRecords(), CollectionUtils.convertMap(faceList, SysUserFaceVo::getUserId)); } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileAccessMobileTest.java b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileAccessMobileTest.java index 60f37bb1f..9c0cd3c73 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileAccessMobileTest.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileAccessMobileTest.java @@ -18,6 +18,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. class FileAccessMobileTest { static final String MOBILE = "https://www.pjzhhq.cn/mobile/"; + static final String GOVERNMENT_ORIGIN = "https://yyfb.idw.jinhua.gov.cn:6443"; + static final String GOVERNMENT_PATH = "/web/mgop/gov-open/zj/2002546649/"; FileAccessProperties settings() { var p = properties("enforce"); p.getClients().remove("h5"); @@ -58,6 +60,82 @@ class FileAccessMobileTest { var p = settings(); p.setPublicImageRefererPrefixes(List.of()); assertEquals(403, new FileAccessGuard(p, null, CLOCK).evaluate(image("GET", "a.png", MOBILE)).status()); } + @Test void rootOriginsAllowCrossOriginBrowserReferersWithoutWideningDirectoryPrefixes() { + for (String configured : List.of(GOVERNMENT_ORIGIN, GOVERNMENT_ORIGIN + "/")) { + var p = settings(); p.setPublicImageRefererPrefixes(List.of(MOBILE, configured)); + assertDoesNotThrow(p::validate); + var guard = new FileAccessGuard(p, null, CLOCK); + for (String method : List.of("GET", "HEAD")) { + for (String referer : List.of(GOVERNMENT_ORIGIN, GOVERNMENT_ORIGIN + "/", + GOVERNMENT_ORIGIN + GOVERNMENT_PATH + "reserved/index.html", GOVERNMENT_ORIGIN + "/another-app/", + "https://YYFB.IDW.JINHUA.GOV.CN:6443/")) { + var request = image(method, "a.png", referer); + request.addHeader("Sec-Fetch-Mode", "no-cors"); request.addHeader("Sec-Fetch-Dest", "image"); + assertEquals("public_image_referer", guard.evaluate(request).reason(), referer); + } + } + assertEquals(200, guard.evaluate(image("GET", "a.png", MOBILE)).status()); + assertEquals(403, guard.evaluate(image("GET", "a.png", "https://www.pjzhhq.cn/")).status()); + p.setPublicImageRefererPrefixes(List.of(GOVERNMENT_ORIGIN + GOVERNMENT_PATH)); + assertEquals(403, guard.evaluate(image("GET", "a.png", GOVERNMENT_ORIGIN + "/")).status()); + assertEquals(200, guard.evaluate(image("GET", "a.png", GOVERNMENT_ORIGIN + GOVERNMENT_PATH + "reserved/index.html")).status()); + p.setPublicImageRefererPrefixes(List.of()); + assertEquals(403, guard.evaluate(image("GET", "a.png", GOVERNMENT_ORIGIN + "/")).status()); + } + var p = settings(); p.setPublicImageRefererPrefixes(List.of("https://yyfb.idw.jinhua.gov.cn/")); + var guard = new FileAccessGuard(p, null, CLOCK); + assertEquals(200, guard.evaluate(image("GET", "a.png", "https://yyfb.idw.jinhua.gov.cn:443/")).status()); + assertEquals(403, guard.evaluate(image("GET", "a.png", GOVERNMENT_ORIGIN + "/")).status()); + } + @Test void rootOriginsStillRequireASingleRefererWithExactSchemeHostAndPort() { + var p = settings(); p.setPublicImageRefererPrefixes(List.of(GOVERNMENT_ORIGIN + "/")); + var guard = new FileAccessGuard(p, null, CLOCK); + for (String referer : List.of("https://yyfb.idw.jinhua.gov.cn/", "https://yyfb.idw.jinhua.gov.cn:443/", + "https://yyfb.idw.jinhua.gov.cn:6444/", "http://yyfb.idw.jinhua.gov.cn:6443/", + "https://yyfb.idw.jinhua.gov.cn.evil.example:6443/", "https://other.idw.jinhua.gov.cn:6443/", + "https://yyfb.idw.jinhua.gov.cn:6443@evil.example/", "https://user@yyfb.idw.jinhua.gov.cn:6443/", + "https://evil.example/?next=" + GOVERNMENT_ORIGIN, GOVERNMENT_ORIGIN + "/#/", + GOVERNMENT_ORIGIN + "/%5c", GOVERNMENT_ORIGIN + "/, " + GOVERNMENT_ORIGIN + "/", "not a URL")) { + assertEquals(403, guard.evaluate(image("GET", "a.png", referer)).status(), referer); + } + var request = image("GET", "a.png", null); request.addHeader("Origin", GOVERNMENT_ORIGIN); + assertEquals(403, guard.evaluate(request).status()); + request.addHeader("Referer", GOVERNMENT_ORIGIN + "/"); request.addHeader("Referer", GOVERNMENT_ORIGIN + "/"); + assertEquals(403, guard.evaluate(request).status()); + } + @Test void rootOriginExceptionOnlyReachesStorageForGetAndHeadImages() throws Exception { + var p = settings(); p.setPublicImageRefererPrefixes(List.of(GOVERNMENT_ORIGIN + "/")); + var service = mock(SysFileService.class); var guard = new FileAccessGuard(p, null, CLOCK); + var mvc = MockMvcBuilders.standaloneSetup(new SysFileController(service, guard, p, mock(FileUploadGuard.class))).build(); + for (String file : List.of("a.pdf", "a.apk", "a.log", "a.zip", "a.mp4")) { + mvc.perform(get("/sys-file/oss/file").param("fileName", file).header("Referer", GOVERNMENT_ORIGIN + "/")) + .andExpect(status().isForbidden()).andExpect(header().string("Cache-Control", "no-store")); + } + for (String destination : List.of("document", "iframe", "frame")) { + mvc.perform(get("/sys-file/oss/file").param("fileName", "a.png").header("Referer", GOVERNMENT_ORIGIN + "/") + .header("Sec-Fetch-Dest", destination)).andExpect(status().isForbidden()); + } + mvc.perform(get("/sys-file/oss/file").param("fileName", "a.png").header("Referer", GOVERNMENT_ORIGIN + "/") + .header("Sec-Fetch-Mode", "navigate")).andExpect(status().isForbidden()); + for (String field : List.of("fileName", "appId", "kid", "ts", "sign")) { + var request = image("GET", "a.png", GOVERNMENT_ORIGIN + "/"); request.setParameter(field, "a.png", "b.png"); + assertEquals(400, guard.evaluate(request).status(), field); + } + assertEquals(400, guard.evaluate(image("GET", "a.png\n", GOVERNMENT_ORIGIN + "/")).status()); + for (String method : List.of("POST", "PUT", "DELETE")) { + assertEquals(403, guard.evaluate(image(method, "a.png", GOVERNMENT_ORIGIN + "/")).status()); + } + var upload = new FileUploadGuard(p, (a, k, n, ttl) -> true, null, CLOCK); + assertEquals(403, upload.evaluate(image("POST", "a.png", GOVERNMENT_ORIGIN + "/"), "upload").status()); + verifyZeroInteractions(service); + for (var request : List.of(get("/sys-file/oss/file"), head("/sys-file/oss/file"))) { + mvc.perform(request.param("fileName", "a.png").header("Referer", GOVERNMENT_ORIGIN + "/") + .header("Sec-Fetch-Mode", "no-cors").header("Sec-Fetch-Dest", "image")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control", "private, max-age=300")) + .andExpect(header().string("Vary", "User-Agent, Referer, Sec-Fetch-Mode, Sec-Fetch-Dest")); + } + verify(service, times(2)).getFile(eq("a.png"), any()); + } @Test void navigationDocumentsMalformedRequestsAndUploadsKeepTheirExistingRestrictions() { var p = settings(); var guard = new FileAccessGuard(p, null, CLOCK); for (String file : List.of("a.pdf", "a.apk", "a.log", "a.zip", "a.mp4")) { @@ -86,7 +164,9 @@ class FileAccessMobileTest { assertThrows(IllegalStateException.class, incomplete::validate); var raw = image("GET", "a.png", null); raw.setParameter("appId", "h5"); assertEquals(400, new FileAccessGuard(settings(), null, CLOCK).evaluate(raw).status()); - for (String prefix : List.of("https://www.pjzhhq.cn/", MOBILE + "#/", MOBILE + "?x=1", "https://user@www.pjzhhq.cn/mobile/", "https://www.pjzhhq.cn/mobile", "https://www.pjzhhq.cn/a/../mobile/")) { + for (String prefix : List.of("", "//www.pjzhhq.cn/", "ftp://www.pjzhhq.cn/", MOBILE + "#/", MOBILE + "?x=1", + GOVERNMENT_ORIGIN + "/#/", GOVERNMENT_ORIGIN + "?x=1", "https://user@yyfb.idw.jinhua.gov.cn:6443/", + "https://user@www.pjzhhq.cn/mobile/", "https://www.pjzhhq.cn/mobile", "https://www.pjzhhq.cn/a/../mobile/", "https://www.pjzhhq.cn/%2f/")) { var p = settings(); p.setPublicImageRefererPrefixes(List.of(prefix)); assertThrows(IllegalStateException.class, p::validate); } var p = settings(); p.setPublicImageRefererPrefixes(List.of(MOBILE + "#/")); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/SysUserPageFaceQueryTest.java b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/SysUserPageFaceQueryTest.java new file mode 100644 index 000000000..7852e158f --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/SysUserPageFaceQueryTest.java @@ -0,0 +1,142 @@ +package com.bsd.pjcloud.admin.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto; +import com.bsd.pjcloud.admin.api.dto.UserDTO; +import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo; +import com.bsd.pjcloud.admin.api.vo.UserVO; +import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper; +import com.bsd.pjcloud.admin.mapper.SysUserMapper; +import com.bsd.pjcloud.admin.service.SysFileService; +import com.bsd.pjcloud.admin.utils.flow.FlowUtil; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import org.apache.ibatis.builder.xml.XMLMapperBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.sql.PreparedStatement; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 分页只补全当前页的人脸引用,避免每一页重新聚合整个人脸表。 */ +class SysUserPageFaceQueryTest { + private final SysUserMapper users = mock(SysUserMapper.class); + private final SysUserFaceMapper faces = mock(SysUserFaceMapper.class); + private final SysFileService files = mock(SysFileService.class); + private final FlowUtil flow = mock(FlowUtil.class); + private SysUserServiceImpl service; + + enum Query { + ADMIN, INTERNAL; + + IPage execute(SysUserServiceImpl service, Page page, UserDTO query) { + return this == ADMIN ? service.getUsersWithRolePage(page, query) : service.innerPage(page, query); + } + } + + @BeforeEach + void setup() { + service = new SysUserServiceImpl(null, null, null, null, null, null, faces, + null, null, null, null, null, files, flow); + ReflectionTestUtils.setField(service, "baseMapper", users); + } + + @ParameterizedTest + @EnumSource(Query.class) + void faceLookupUsesOnlyDistinctCurrentPageIdsAndKeepsRawImages(Query method) throws Exception { + var page = new Page(2, 5); + var query = queryWithoutApproval(); + var rows = List.of(user(101L), user(202L), user(101L), user(null), user(303L)); + var result = new Page(2, 5).setRecords(rows).setTotal(120); + when(users.getUserVosPage(same(page), same(query), nullable(DataScope.class))).thenReturn(result); + String firstImages = "/admin/sys-file/oss/file?fileName=first.jpg,/admin/sys-file/oss/file?fileName=second.png"; + String secondImages = "https://files.example/api/admin/sys-file/oss/file?fileName=third.jpg"; + when(faces.groupConcatFaces(any())).thenReturn(List.of(face(202L, secondImages), face(101L, firstImages))); + + IPage actual = method.execute(service, page, query); + + var captured = ArgumentCaptor.forClass(SysUserFaceDto.class); + verify(faces).groupConcatFaces(captured.capture()); + assertEquals(List.of(101L, 202L, 303L), captured.getValue().getUserIds()); + assertSame(result, actual); + assertSame(rows, actual.getRecords()); + assertEquals(120, actual.getTotal()); + assertEquals(firstImages, rows.get(0).getFaceImgs()); + assertEquals(secondImages, rows.get(1).getFaceImgs()); + assertEquals(firstImages, rows.get(2).getFaceImgs()); + assertNull(rows.get(3).getFaceImgs()); + assertNull(rows.get(4).getFaceImgs()); + verifyZeroInteractions(files, flow); + + // Parse the production XML and bind the captured DTO, proving the filter reaches SQL. + var configuration = new MybatisConfiguration(); + String resource = "mapper/SysUserFaceMapper.xml"; + try (var input = getClass().getClassLoader().getResourceAsStream(resource)) { + assertNotNull(input); + new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); + } + var statement = configuration.getMappedStatement("com.bsd.pjcloud.admin.mapper.SysUserFaceMapper.groupConcatFaces"); + Map parameters = Map.of("pa", captured.getValue()); + var bound = statement.getBoundSql(parameters); + String sql = bound.getSql().replaceAll("\\s+", " "); + assertTrue(sql.matches("(?s).*user_id\\s+in\\s*\\(\\s*\\?\\s*,\\s*\\?\\s*,\\s*\\?\\s*\\).*"), sql); + assertEquals(3, bound.getParameterMappings().size()); + var prepared = mock(PreparedStatement.class); + configuration.newParameterHandler(statement, parameters, bound).setParameters(prepared); + verify(prepared).setLong(1, 101L); + verify(prepared).setLong(2, 202L); + verify(prepared).setLong(3, 303L); + verifyNoMoreInteractions(prepared); + } + + @ParameterizedTest + @EnumSource(Query.class) + void emptyPageNeverQueriesFaces(Query method) { + assertNoFaceLookup(method, List.of()); + } + + @ParameterizedTest + @EnumSource(Query.class) + void pageWithoutAnyValidUserIdNeverFallsBackToUnfilteredFaceQuery(Query method) { + assertNoFaceLookup(method, List.of(user(null), user(null))); + } + + private void assertNoFaceLookup(Query method, List rows) { + var page = new Page(1, 10); + var query = queryWithoutApproval(); + var result = new Page(1, 10).setRecords(rows); + when(users.getUserVosPage(same(page), same(query), nullable(DataScope.class))).thenReturn(result); + + assertSame(result, method.execute(service, page, query)); + + verifyZeroInteractions(faces, files, flow); + } + + private UserDTO queryWithoutApproval() { + var query = new UserDTO(); + query.setIsFindCheckUser(false); + return query; + } + + private UserVO user(Long id) { + var user = new UserVO(); + user.setUserId(id); + return user; + } + + private SysUserFaceVo face(Long id, String images) { + var face = new SysUserFaceVo(); + face.setUserId(id); + face.setFaceImgs(images); + return face; + } +}