diff --git a/docs/file-access/README.md b/docs/file-access/README.md index 029f5168e..10de13325 100644 --- a/docs/file-access/README.md +++ b/docs/file-access/README.md @@ -144,7 +144,7 @@ JPEG、PNG、GIF、BMP、WebP 位图按实际文件头识别和校验:声明 | 部分 | 接入位置与行为 | | --- | --- | | Java 文件入口 | `FileAccessGuard`,`SysFileController`,`SysFileServiceImpl`;租户跳过只作用于文件查询并恢复原状态 | -| Java 内部读取 | `RemoteFileService` 保留 `getFile` 调用,通过编码桥接和专属 Feign 拦截器签名,保留文件名中的字面 `%xx`;钱包、硬件导出/日志/取证通过相同签名读取;票据 URLConnection 单独适配 | +| Java 内部读取 | `RemoteFileService.getFile` 通过编码桥接和专属 Feign 拦截器签名,保留文件名中的字面 `%xx`;钱包、硬件导出/日志/取证及发票盖章通过相同签名读取 | | APK | 终端 `/app-version/ad/page` 和 `/app/last-version` 返回对象使用 `package` 签名,后台列表/编辑和数据库仍为原始值;独立静态 APK 地址保持不变 | | 后台 | `src/utils/file-access*.ts`;`FileImage` 保留已加载画面,未加载的懒图片继续续期,打开/切换预览时取有效签名;失败重试仅刷新当前文件,不清空全局签名缓存;上传回显、素材库、头像、视频、版本复制和下载均已接入;KKFileView 在编码之前签名源地址 | | 后台编辑器 | 只更新编辑器 DOM 的资源属性,不替换整篇文档;输出 HTML 剥离临时签名,续期不进入编辑历史 | @@ -156,6 +156,28 @@ JPEG、PNG、GIF、BMP、WebP 位图按实际文件头识别和校验:声明 第三方 URL、静态资源、`data:`、`blob:`、本地临时文件不加本协议签名。复制访问链接是临时访问地址;编辑、存储文件引用使用原始值。 +### Java 图片合成等内部读取 + +文件中心图片使用 `RemoteFileService.getFile(fileName)`,每次调用由专属 Feign 拦截器生成 `service` 签名,并通过服务发现访问 UPMS。业务字段如果保存的是完整或相对文件链接,先用 `FileAccessUrl.fileName(source)` 提取原始文件名: + +```java +String fileName = FileAccessUrl.fileName(source); +if (fileName == null) throw new IllegalArgumentException("不是文件中心引用"); +try (Response response = remoteFileService.getFile(fileName)) { + if (response.status() < 200 || response.status() >= 300 || response.body() == null) { + throw new IOException("文件读取失败"); + } + try (InputStream input = response.body().asInputStream()) { + BufferedImage image = ImageIO.read(input); + // 校验 image 非空后合成。 + } +} +``` + +提取工具支持原始文件名、`/sys-file/oss/file`、`/admin/sys-file/oss/file` 及带网关前缀的 `/admin/sys-file/oss/file` 链接;查询中的文件名只解码一次,重复、缺失或非法文件名拒绝处理。链接中的主机和旧签名不参与内部调用,始终读取当前环境的 UPMS,不会向原链接主机发送 `service` 凭据。 + +`WalletRechargeInvoiceServiceImpl.stamp` 已按此方式读取底图。因此 `http://127.0.0.1:9999/admin/sys-file/oss/file?...`、历史域名链接和相对链接都不再依赖 `IMAGE_BASE_URL` 或公网来源列表。非文件中心图片保留原有 HTTP 读取方式,相对静态资源仍使用 `IMAGE_BASE_URL`。调用方和 UPMS 必须加载相同的 `clients.service` 活动密钥;本次修复需要重新构建并发布 Wallet 及更新后的 common-core,无需为本机地址扩大 `allowed-origins`。 + 旧终端兼容只接受完整 `Dart/<版本> (dart:io)` UA,且扩展名在 jpg/jpeg/png/gif/webp/bmp/svg/ico 内。完全没有签名参数才可走兼容分支;错误或过期签名不会回退。APK、日志、文档不凭 Dart UA 放行。 ## 发布和回滚 @@ -188,7 +210,7 @@ Java 定向回归: ```bash mvn -pl pjcloud-upms/pjcloud-upms-biz,pjcloud-integrated/pjcloud-integrated-biz,pjcloud-wallet/pjcloud-wallet-biz,pjcloud-hardware/pjcloud-hardware-biz,pjcloud-canteen/pjcloud-canteen-biz \ -am test \ - -Dtest=FileAccessTest,FileReadTest,FileUploadGuardTest,FileUploadPolicyTest,FileUploadControllerTest,AppVersionFileAccessTest,WalletManageBatchExportTest,WalletLedgerBatchExportTest,HardBatchExportFileTest,HardBatchExportQueryTest \ + -Dtest=FileAccessTest,FileReadTest,FileUploadGuardTest,FileUploadPolicyTest,FileUploadControllerTest,AppVersionFileAccessTest,FileAccessUrlTest,WalletRechargeInvoiceFileAccessTest,WalletManageBatchExportTest,WalletLedgerBatchExportTest,HardBatchExportFileTest,HardBatchExportQueryTest \ -Dsurefire.failIfNoSpecifiedTests=false ``` diff --git a/docs/file-access/verification.md b/docs/file-access/verification.md index da1465b4a..b14481f0f 100644 --- a/docs/file-access/verification.md +++ b/docs/file-access/verification.md @@ -150,3 +150,15 @@ Java 定向测试及各端构建命令见 [接入与发布说明](README.md)。 - 执行 `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 和对应公共依赖发布后确认。若根来源配置共享给其他启用文件配置的服务,也需先更新这些服务的公共依赖。 + +## 发票盖章内部读取修复 + +2026-10-30 反馈 `WalletRechargeInvoiceServiceImpl.stamp` 访问 `http://127.0.0.1:9999/admin/sys-file/oss/file?fileName=...` 返回 403。原实现使用 `FileAccessSigner.signUrl`,不在公网来源配置中的主机不会被签名,随后 URLConnection 发出的裸请求在严格模式被拒绝。 + +盖章现在使用 `FileAccessUrl.fileName` 解析文件引用,再由 `RemoteFileService.getFile` 通过服务发现读取 UPMS,并在请求时生成 `service` 签名。文件中心读取不再依赖 `IMAGE_BASE_URL` 或原链接的主机;文件访问入口及其鉴权规则未修改。 + +- 执行 `mvn -pl pjcloud-wallet/pjcloud-wallet-biz,pjcloud-upms/pjcloud-upms-biz -am test -Dtest=WalletRechargeInvoiceFileAccessTest,FileAccessUrlTest,FileAccessTest,FileAccessMobileTest,FileUploadGuardTest -Dsurefire.failIfNoSpecifiedTests=false`:31 项通过,0 失败、错误或跳过,Maven 构建成功。 +- 新回归覆盖本机网关、历史域名及网关前缀、相对路径、原始文件名、旧签名替换、中文/加号/百分号编码、重复与非法文件名、读取失败不上传、源流关闭、PNG 盖章合成,以及第三方图片请求保持原样。 +- 本机验证日志为 `/tmp/pj-internal-image-access-tests.log`。测试中的 Feign 文件服务为替身,未访问真实注册中心或存储;未修改 Nacos、密钥或运行中的服务。 + +需重新构建并发布 Wallet,包含更新后的 common-core;确认 Wallet 与 UPMS 的 `clients.service` 密钥一致,再在实际环境重新生成发票验收。 diff --git a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessUrl.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessUrl.java new file mode 100644 index 000000000..dadd17055 --- /dev/null +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessUrl.java @@ -0,0 +1,56 @@ +package com.bsd.pjcloud.common.core.file; + +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +/** Resolves stored file references for internal reads through RemoteFileService. */ +public final class FileAccessUrl { + private FileAccessUrl() { } + + /** + * Returns the original filename, or null for a URL outside the file-center routes. + * The source host is deliberately not used: callers must read the result from + * their own UPMS, never sign or fetch the supplied host with service credentials. + */ + public static String fileName(String source) { + if (source == null || source.isBlank()) throw new IllegalArgumentException("Empty file reference"); + String value = source.trim(); + if (!value.contains("/") && !value.contains("?") && !value.contains(":")) { + return validateFileName(value); + } + URI uri = URI.create(value); + String path = uri.getRawPath(); + if (path == null) return null; + if (!path.startsWith("/")) path = "/" + path; + if (!(path.equals("/sys-file/oss/file") || path.endsWith("/admin/sys-file/oss/file"))) return null; + if ((uri.isAbsolute() && !("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))) + || (!uri.isAbsolute() && uri.getRawAuthority() != null) || uri.getUserInfo() != null + || uri.getRawFragment() != null) { + throw new IllegalArgumentException("Invalid file-center URL"); + } + String name = null; + if (uri.getRawQuery() != null) { + for (String pair : uri.getRawQuery().split("&")) { + String[] parts = pair.split("=", 2); + if (decode(parts[0]).equals("fileName")) { + if (name != null) throw new IllegalArgumentException("Duplicate fileName"); + name = decode(parts.length == 2 ? parts[1] : ""); + } + } + } + return validateFileName(name); + } + + private static String validateFileName(String name) { + if (name == null || name.isBlank() || name.length() > 1024 || name.contains("/") || name.contains("\\") + || name.equals(".") || name.equals("..") || name.chars().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException("Invalid file-center filename"); + } + return name; + } + + private static String decode(String value) { + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeInvoiceServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeInvoiceServiceImpl.java index f006fd816..84b304a0c 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeInvoiceServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeInvoiceServiceImpl.java @@ -21,6 +21,7 @@ import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; import com.bsd.pjcloud.admin.api.feign.RemoteFileService; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.common.core.file.FileAccessUrl; import com.bsd.pjcloud.common.core.util.CollectionUtils; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; @@ -54,6 +55,7 @@ import com.bsd.pjcloud.wallet.utils.ConvertCnPriceUtil; import com.bsd.pjcloud.wallet.utils.flow.FlowUtil; import com.bsd.pjcloud.wallet.utils.flow.dto.WalletRechargeInvoiceFlowDto; import com.bsd.pjcloud.wallet.utils.page.PaginationOrderSqlExtractor; +import feign.Response; import jakarta.servlet.http.HttpServletResponse; import org.springframework.mock.web.MockMultipartFile; import org.springframework.transaction.annotation.Transactional; @@ -66,6 +68,7 @@ import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.InputStream; import java.math.BigDecimal; import java.math.RoundingMode; @@ -406,9 +409,6 @@ public class WalletRechargeInvoiceServiceImpl extends ServiceImpl= 300 || response.body() == null) { + throw new IOException("读取发票底图失败,文件服务状态:" + (response == null ? "无响应" : response.status())); + } + try (InputStream inputStream = response.body().asInputStream()) { + return ImageIO.read(inputStream); + } + } + } + // 保留非文件中心图片原有的读取方式,不向第三方地址附加服务签名。 + String url = source; + if (!url.startsWith("http://") && !url.startsWith("https://")) { + String imageBaseUrl = ParamResolver.getStr("IMAGE_BASE_URL"); + if (ObjectUtil.isEmpty(imageBaseUrl)) throw new IOException("请配置图片基础路径"); + url = imageBaseUrl.replaceAll("/+$", "") + "/" + url.replaceAll("^/+", ""); + } + URLConnection connection = new URL(url).openConnection(); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + try (InputStream inputStream = connection.getInputStream()) { + return ImageIO.read(inputStream); + } + } + private static int clamp(int value, int min, int max) { return Math.max(min, Math.min(value, max)); } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/FileAccessUrlTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/FileAccessUrlTest.java new file mode 100644 index 000000000..f35e9b0a3 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/FileAccessUrlTest.java @@ -0,0 +1,43 @@ +package com.bsd.pjcloud.wallet.service.impl.recharge; + +import com.bsd.pjcloud.common.core.file.FileAccessUrl; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class FileAccessUrlTest { + @Test + void queryIsDecodedOnceWhileBareFileNamesKeepLiteralEscapes() { + assertEquals("照片 +&%2F.png", FileAccessUrl.fileName( + "/admin/sys-file/oss/file?fileName=%E7%85%A7%E7%89%87%20%2B%26%252F.png")); + assertEquals("literal%2F.png", FileAccessUrl.fileName("literal%2F.png")); + assertEquals("base.png", FileAccessUrl.fileName( + "http://127.0.0.1:9999/api/admin/sys-file/oss/file?%66ileName=base.png")); + } + + @Test + void otherImageUrlsAreNotTreatedAsFileCenterObjects() { + for (String source : List.of("https://other.example/static/a.png", "/static/a.png", + "https://other.example/not-admin/sys-file/oss/file?fileName=a.png", + "https://other.example/admin/sys-file/oss/file-extra?fileName=a.png")) { + assertNull(FileAccessUrl.fileName(source), source); + } + } + + @Test + void ambiguousMalformedOrUnsafeFileNamesAreRejected() { + for (String source : List.of("", "..", "bad\\name.png", "/admin/sys-file/oss/file", + "/admin/sys-file/oss/file?fileName=a.png&%66ileName=b.png", + "/admin/sys-file/oss/file?fileName=%ZZ", + "/admin/sys-file/oss/file?fileName=a%0Ab.png", + "/admin/sys-file/oss/file?fileName=dir%2Fa.png", + "/admin/sys-file/oss/file?fileName=dir%5Ca.png", + "https://user@other.example/admin/sys-file/oss/file?fileName=a.png", + "//other.example/admin/sys-file/oss/file?fileName=a.png", + "/admin/sys-file/oss/file?fileName=a.png#fragment")) { + assertThrows(IllegalArgumentException.class, () -> FileAccessUrl.fileName(source), source); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeInvoiceFileAccessTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeInvoiceFileAccessTest.java new file mode 100644 index 000000000..36a1629ff --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeInvoiceFileAccessTest.java @@ -0,0 +1,201 @@ +package com.bsd.pjcloud.wallet.service.impl.recharge; + +import com.bsd.pjcloud.admin.api.config.FileAccessFeignConfiguration; +import com.bsd.pjcloud.admin.api.feign.RemoteFileService; +import com.bsd.pjcloud.common.core.file.FileAccessProperties; +import com.bsd.pjcloud.common.core.file.FileAccessSignature; +import com.bsd.pjcloud.common.core.file.FileAccessSigner; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.flow.task.constant.TaskCheckStatusEnum; +import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeInvoiceEntity; +import com.sun.net.httpserver.HttpServer; +import feign.Feign; +import feign.Request; +import feign.Response; +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.support.SpringMvcContract; +import org.springframework.web.multipart.MultipartFile; + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class WalletRechargeInvoiceFileAccessTest { + private static final String STAMPED_URL = "/admin/sys-file/oss/file?fileName=stamped.png"; + private final RemoteFileService files = mock(RemoteFileService.class); + private final WalletRechargeInvoiceServiceImpl service = new WalletRechargeInvoiceServiceImpl( + null, null, null, null, null, files, null, null); + + @Test + void localGatewayAndStoredReferencesUseFreshServiceSignaturesWithoutPublicOriginConfiguration() throws Exception { + long now = 1_800_000_000L; + String key = Base64.getEncoder().encodeToString("0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8)); + var properties = new FileAccessProperties(); + properties.setPublicBaseUrl("https://public.example/api"); + var client = new FileAccessProperties.Client(); + client.setKeys(Map.of("v1", key)); + properties.setClients(Map.of("service", client)); + var signer = new FileAccessSigner(properties, Clock.fixed(Instant.ofEpochSecond(now), ZoneOffset.UTC)); + byte[] image = image(); + var captured = new AtomicReference(); + RemoteFileService signedClient = Feign.builder().contract(new SpringMvcContract()) + .requestInterceptor(new FileAccessFeignConfiguration().fileAccessRequestInterceptor(signer)) + .client((request, options) -> { + captured.set(request); + return Response.builder().request(request).status(200).reason("OK").headers(Map.of()).body(image).build(); + }).target(RemoteFileService.class, "http://upms-internal"); + when(files.getFile(anyString())).thenAnswer(call -> signedClient.getFile(call.getArgument(0))); + when(files.innerUpload(any(), isNull(), isNull(), isNull())).thenReturn(R.ok(Map.of("url", STAMPED_URL))); + String name = "发票 +&%2F.png"; + String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20"); + + for (String source : List.of( + "http://127.0.0.1:9999/admin/sys-file/oss/file?fileName=" + encoded, + "/admin/sys-file/oss/file?fileName=" + encoded, + "admin/sys-file/oss/file?fileName=" + encoded, + "/api/admin/sys-file/oss/file?fileName=" + encoded, + "/sys-file/oss/file?fileName=" + encoded, + "https://old.example/gateway/api/admin/sys-file/oss/file?appId=admin&kid=old&ts=1&sign=expired&fileName=" + encoded, + name)) { + assertEquals(STAMPED_URL, service.stamp(invoice(source)), source); + URI request = URI.create(captured.get().url()); + assertEquals("upms-internal", request.getHost()); + assertEquals("/sys-file/oss/file", request.getPath()); + Map params = query(request); + assertEquals(name, params.get("fileName")); + assertEquals("service", params.get("appId")); + assertEquals("v1", params.get("kid")); + assertEquals(Long.toString(now), params.get("ts")); + assertEquals(FileAccessSignature.sign(name, "service", "v1", now, key), params.get("sign")); + } + verify(files, times(7)).getFile(name); + } + + @Test + void readsAndClosesSourceBeforeUploadingACompositedImage() throws Exception { + byte[] image = image(); + var closed = new AtomicBoolean(); + var input = new ByteArrayInputStream(image) { + @Override public void close() throws IOException { closed.set(true); super.close(); } + }; + when(files.getFile("base.png")).thenReturn(response(200).body(input, image.length).build()); + when(files.innerUpload(any(), isNull(), isNull(), isNull())).thenAnswer(call -> { + assertTrue(closed.get()); + MultipartFile upload = call.getArgument(0); + assertEquals("image/png", upload.getContentType()); + BufferedImage stamped = ImageIO.read(upload.getInputStream()); + assertNotNull(stamped); + assertEquals(700, stamped.getWidth()); + assertEquals(400, stamped.getHeight()); + assertTrue(java.util.Arrays.stream(stamped.getRGB(0, 0, 700, 400, null, 0, 700)) + .anyMatch(pixel -> pixel != Color.WHITE.getRGB())); + return R.ok(Map.of("url", STAMPED_URL)); + }); + + assertEquals(STAMPED_URL, service.stamp(invoice("/admin/sys-file/oss/file?fileName=base.png"))); + verify(files).innerUpload(any(), isNull(), isNull(), isNull()); + } + + @Test + void failedMissingAndNonImageResponsesNeverUploadAStampedResult() { + for (Response response : List.of( + response(403).body("forbidden", StandardCharsets.UTF_8).build(), + response(404).body(new byte[0]).build(), + response(500).body(new byte[0]).build(), + response(200).build(), + response(200).body("not an image", StandardCharsets.UTF_8).build())) { + when(files.getFile("base.png")).thenReturn(response); + assertNull(service.stamp(invoice("/admin/sys-file/oss/file?fileName=base.png"))); + } + verify(files, never()).innerUpload(any(), any(), any(), any()); + } + + @Test + void malformedFileCenterReferencesFailBeforeAnyRemoteReadOrUpload() { + for (String source : List.of( + "/admin/sys-file/oss/file?fileName=one.png&fileName=two.png", + "/admin/sys-file/oss/file?fileName=", + "/admin/sys-file/oss/file?fileName=..%2Fsecret.png", + "file:/admin/sys-file/oss/file?fileName=base.png")) { + assertNull(service.stamp(invoice(source))); + } + verifyZeroInteractions(files); + } + + @Test + void thirdPartyImagesKeepTheirOriginalHttpRequestWithoutServiceCredentials() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + var sourceRequest = new AtomicReference(); + byte[] image = image(); + server.createContext("/external.png", exchange -> { + sourceRequest.set(exchange.getRequestURI()); + exchange.sendResponseHeaders(200, image.length); + try (var output = exchange.getResponseBody()) { output.write(image); } + }); + server.start(); + try { + when(files.innerUpload(any(), isNull(), isNull(), isNull())).thenReturn(R.ok(Map.of("url", STAMPED_URL))); + String source = "http://127.0.0.1:" + server.getAddress().getPort() + "/external.png?version=1"; + assertEquals(STAMPED_URL, service.stamp(invoice(source))); + assertEquals("/external.png?version=1", sourceRequest.get().toString()); + verify(files, never()).getFile(anyString()); + } finally { + server.stop(0); + } + } + + private WalletRechargeInvoiceEntity invoice(String source) { + var invoice = new WalletRechargeInvoiceEntity(); + invoice.setId(1L); + invoice.setProcessStatus(TaskCheckStatusEnum.RESULT.getCode()); + invoice.setPicUrl(source); + return invoice; + } + + private Response.Builder response(int status) { + return Response.builder().request(Request.create(Request.HttpMethod.GET, "http://upms-internal/sys-file/oss/file", + Map.of(), null, StandardCharsets.UTF_8, null)).status(status).reason("test").headers(Map.of()); + } + + private byte[] image() throws IOException { + BufferedImage image = new BufferedImage(700, 400, BufferedImage.TYPE_INT_RGB); + var graphics = image.createGraphics(); + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, image.getWidth(), image.getHeight()); + graphics.dispose(); + try (var output = new ByteArrayOutputStream()) { + ImageIO.write(image, "PNG", output); + return output.toByteArray(); + } + } + + private Map query(URI uri) { + Map values = new LinkedHashMap<>(); + for (String pair : uri.getRawQuery().split("&")) { + String[] parts = pair.split("=", 2); + assertNull(values.put(parts[0], URLDecoder.decode(parts[1], StandardCharsets.UTF_8))); + } + return values; + } +}