From 5d8868482092de0a443d438e1691cc2589b6c720 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Thu, 10 Sep 2026 14:47:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(hjc):=20=E8=AF=81=E4=BB=B6=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E6=94=B9=E4=B8=BA=E5=AD=98=E5=85=A5=20OSS=EF=BC=88?= =?UTF-8?q?=E6=AD=A4=E5=89=8D=E5=8F=AA=E5=86=99=E6=9C=AC=E5=9C=B0=E7=9B=98?= =?UTF-8?q?=E3=80=81=E8=BF=94=E5=9B=9E=E7=9A=84=E5=9C=B0=E5=9D=80=E6=89=93?= =?UTF-8?q?=E4=B8=8D=E5=BC=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:上传接口只把文件写进本地磁盘,再把地址拼成 {bucketDomain}{path} 或 {file-server}/api/file{path}——文件从未进过 OSS;前者指向桶里不存在的对象, 后者该前缀在文件服务主机上是 nginx 404。两条分支给出的地址都打不开, 证件预览坏、OCR 取图也必然失败。 做法(照后台 hjc-vue 的姿势):新增 HjcOssUploadUtil,把文件转交统一 OSS 上传服务 POST {config.server-url}/oss/upload(multipart file + tenantId 头,匿名可用), 取 data.path(原图,避开 data.url 的 x-oss-process 缩略参数)作为地址。 - 不转发调用方的 Authorization:hjc 的 JWT 与核心后端不是同一套签名,转过去会被判 401 - part 的 Content-Type 保留为图片类型(否则 OSS 存成 octet-stream,浏览器变下载、 且 OCR 取图的 image/* 校验会拒掉自家上传的图) - 上传失败即整个上传失败,不再回退成打不开的地址 - 未改动 common 的 FileController(多项目共用代码) 新增打桩单测 10 个(请求形状、path 优先、缩略参数剥离、失败码/非 JSON/无地址、类型嗅探)。 --- .../hjc/controller/HjcAuthController.java | 34 +-- .../gxwebsoft/hjc/util/HjcOssUploadUtil.java | 171 +++++++++++++++ .../hjc/util/HjcOssUploadUtilTest.java | 198 ++++++++++++++++++ 3 files changed, 379 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/hjc/util/HjcOssUploadUtil.java create mode 100644 src/test/java/com/gxwebsoft/hjc/util/HjcOssUploadUtilTest.java diff --git a/src/main/java/com/gxwebsoft/hjc/controller/HjcAuthController.java b/src/main/java/com/gxwebsoft/hjc/controller/HjcAuthController.java index b49a405..e76119d 100644 --- a/src/main/java/com/gxwebsoft/hjc/controller/HjcAuthController.java +++ b/src/main/java/com/gxwebsoft/hjc/controller/HjcAuthController.java @@ -1,13 +1,10 @@ package com.gxwebsoft.hjc.controller; import cn.hutool.core.util.StrUtil; -import com.alibaba.fastjson.JSONObject; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.gxwebsoft.common.core.config.ConfigProperties; import com.gxwebsoft.common.core.security.JwtSubject; import com.gxwebsoft.common.core.security.JwtUtil; -import com.gxwebsoft.common.core.utils.FileServerUtil; -import com.gxwebsoft.common.core.utils.RedisUtil; import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.system.entity.Role; @@ -22,8 +19,10 @@ import com.gxwebsoft.hjc.entity.HjcEnterprise; import com.gxwebsoft.hjc.entity.HjcEnterpriseMaterial; import com.gxwebsoft.hjc.service.HjcEnterpriseMaterialService; import com.gxwebsoft.hjc.service.HjcEnterpriseService; +import com.gxwebsoft.hjc.util.HjcOssUploadUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -33,7 +32,6 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import javax.annotation.Resource; -import java.io.File; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -43,6 +41,7 @@ import java.util.Map; * 汇吉采 网站企业 注册/登录(企业名称+密码) */ @Tag(name = "汇吉采-企业登录") +@Slf4j @RestController @RequestMapping("/api/hjc/auth") public class HjcAuthController extends BaseController { @@ -60,43 +59,30 @@ public class HjcAuthController extends BaseController { @Resource private HjcEnterpriseMaterialService hjcEnterpriseMaterialService; @Resource - private RedisUtil redisUtil; + private HjcOssUploadUtil hjcOssUploadUtil; /** 注册必传的资质证件材料类型 */ private static final List REQUIRED_MATERIAL_TYPES = Arrays.asList("idcard_front", "idcard_back", "handbook", "license"); - @Operation(summary = "注册证件上传(匿名,注册页专用,不写文件表)") + @Operation(summary = "注册证件上传(匿名,注册页与资质页通用;文件存入 OSS)") @PostMapping("/upload") public ApiResult> upload(@RequestParam("file") MultipartFile file) { if (file == null || file.isEmpty()) { return fail("上传文件不能为空", null); } try { - String dir = configProperties.getUploadPath(); - File upload = FileServerUtil.upload(file, dir, Boolean.TRUE.equals(configProperties.getUploadUuidName())); - String path = upload.getAbsolutePath().replace("\\", "/").substring(dir.length() - 1); - // 默认走自建文件服务器 - String url = configProperties.getFileServer() + "/api/file" + path; - // 云存储(OSS)时改用 bucketDomain Integer tenantId = getTenantId(); - if (tenantId != null) { - JSONObject setting = JSONObject.parseObject(redisUtil.get("setting:upload:" + tenantId)); - if (setting != null) { - String uploadMethod = setting.getString("uploadMethod"); - String bucketDomain = setting.getString("bucketDomain"); - if (StrUtil.isNotBlank(uploadMethod) && !"file".equals(uploadMethod) - && StrUtil.isNotBlank(bucketDomain)) { - url = bucketDomain + path; - } - } - } + // 委托统一 OSS 上传服务(server.websoft.top/api/oss/upload):它会按租户取云存储配置、 + // 把文件写进 OSS 并返回可访问地址。此前这里只写本地磁盘再拼 OSS 域名,文件从未进过 OSS。 + String url = hjcOssUploadUtil.upload(file, tenantId == null ? null : tenantId.toString()); Map data = new HashMap<>(4); data.put("url", url); data.put("name", file.getOriginalFilename()); return success(data); } catch (Exception e) { - e.printStackTrace(); + log.warn("HjcUpload: 证件上传失败 tenantId={} name={} size={}", + getTenantId(), file.getOriginalFilename(), file.getSize(), e); return fail("上传失败", null); } } diff --git a/src/main/java/com/gxwebsoft/hjc/util/HjcOssUploadUtil.java b/src/main/java/com/gxwebsoft/hjc/util/HjcOssUploadUtil.java new file mode 100644 index 0000000..2bb8515 --- /dev/null +++ b/src/main/java/com/gxwebsoft/hjc/util/HjcOssUploadUtil.java @@ -0,0 +1,171 @@ +package com.gxwebsoft.hjc.util; + +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSONObject; +import com.gxwebsoft.common.core.config.ConfigProperties; +import lombok.extern.slf4j.Slf4j; +import okhttp3.MediaType; +import okhttp3.MultipartBody; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.apache.tika.Tika; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import javax.annotation.Resource; +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** + * 证件图片上传:委托统一的 OSS 上传服务。 + * + *

为什么是「委托」而不是自己 putObject:平台里已有一个可用的 OSS 上传服务 + * (`{@code config.server-url}/oss/upload`,即 {@code server.websoft.top/api},与 + * {@code RequestUtil} 调的是同一个后端),后台(hjc-vue 的 {@code UploadImage})正是走它上传图片—— + * 它会按 {@code tenantId} 取该租户的云存储配置、把文件写进 OSS 并返回可访问地址。 + * 此前 hjc 的证件上传只写本地磁盘、再把地址拼成 OSS 域名,**文件从未进过 OSS**, + * 于是返回的地址打不开(渲染 404、OCR 取图也 404)。这里按同一姿势委托上传,避免在 mp-java 里 + * 另起一套 OSS 凭据与代码({@code config.*} 里那套 AK 是给别的用途的,不该在这里重复消费)。

+ * + *

不转发调用方的 Authorization:该服务实测匿名即可用(只认 {@code tenantId} 头), + * 而 hjc 前端的 JWT 与核心后端不是同一套签名,转发过去只会被判 401。故只带 {@code tenantId}。

+ * + *

multipart 的 part 类型要正确:该服务把上传 part 的 Content-Type 直接写成 OSS 对象元数据 + * (实测:part 传 image/jpeg,OSS 返回的 Content-Type 就是 image/jpeg)。若传成 + * application/octet-stream,浏览器会变成下载、且 {@code HjcOcrImageFetcher} 的 {@code image/*} 校验会把 + * 自家上传的证件图拒掉,因此这里显式解析/嗅探图片类型。

+ */ +@Slf4j +@Component +public class HjcOssUploadUtil { + + /** 统一 OSS 上传服务路径(相对 {@code config.server-url}) */ + static final String OSS_UPLOAD_PATH = "/oss/upload"; + /** 连接超时 */ + static final int CONNECT_TIMEOUT_MS = 3000; + /** 读写超时:证件图最大 5M,跨服务上传给足时间 */ + static final int READ_TIMEOUT_MS = 20000; + + private static final Tika TIKA = new Tika(); + + @Resource + private ConfigProperties configProperties; + + private final OkHttpClient http = new OkHttpClient.Builder() + .connectTimeout(CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .readTimeout(READ_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .writeTimeout(READ_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .build(); + + /** + * 上传文件到 OSS,返回可访问的地址 + * + * @param file 上传的文件(证件图片) + * @param tenantId 租户 ID:OSS 上传服务据此取该租户的云存储配置 + * @return 图片地址(原图地址,不带缩略参数) + * @throws IOException 上传失败(服务不可达、返回失败码、未返回地址) + */ + public String upload(MultipartFile file, String tenantId) throws IOException { + byte[] bytes = file.getBytes(); + String filename = StrUtil.blankToDefault(file.getOriginalFilename(), "upload"); + RequestBody filePart = RequestBody.create(bytes, MediaType.parse(resolveContentType(file, bytes, filename))); + MultipartBody body = new MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", filename, filePart) + .build(); + Request.Builder builder = new Request.Builder() + .url(configProperties.getServerUrl() + OSS_UPLOAD_PATH) + .post(body); + if (StrUtil.isNotBlank(tenantId)) { + builder.header("tenantId", tenantId); + } + String responseBody = execute(builder.build()); + String url = extractUrl(responseBody); + log.info("HjcUpload: 证件已上传 OSS tenantId={} name={} bytes={} url={}", + tenantId, filename, bytes.length, url); + return url; + } + + /** + * 执行 HTTP 调用并返回响应体。该方法是唯一的 HTTP 出口,测试中可覆盖以注入打桩响应。 + */ + protected String execute(Request request) throws IOException { + try (Response response = http.newCall(request).execute()) { + String body = response.body() == null ? null : response.body().string(); + if (!response.isSuccessful()) { + throw new IOException("OSS 上传服务返回 HTTP " + response.code() + ":" + brief(body)); + } + if (StrUtil.isBlank(body)) { + throw new IOException("OSS 上传服务返回空响应"); + } + return body; + } + } + + /** + * 从响应中取可访问地址:响应形如 + * {@code {"code":0,"message":"操作成功","data":{"path":"https://oss.wsdns.cn/2026…/x.jpg","url":"…?x-oss-process=…"}}} + * + *

优先取 {@code data.path}:{@code data.url} 会带 {@code ?x-oss-process} 缩略参数, + * 而证件要原图(缩略会影响证件识别的清晰度)。

+ */ + static String extractUrl(String responseBody) throws IOException { + JSONObject json; + try { + json = JSONObject.parseObject(responseBody); + } catch (Exception e) { + throw new IOException("OSS 上传服务响应不是合法 JSON:" + brief(responseBody)); + } + if (json == null || json.getIntValue("code") != 0) { + String message = json == null ? null : json.getString("message"); + String error = json == null ? null : json.getString("error"); + throw new IOException("OSS 上传服务返回失败:" + message + " " + brief(error)); + } + JSONObject data = json.getJSONObject("data"); + String path = data == null ? null : data.getString("path"); + if (isHttpUrl(path)) { + return path; + } + String url = data == null ? null : data.getString("url"); + if (isHttpUrl(url)) { + // 兜底:去掉 x-oss-process 之类的处理参数,尽量拿原图 + int queryIndex = url.indexOf('?'); + return queryIndex > 0 ? url.substring(0, queryIndex) : url; + } + throw new IOException("OSS 上传服务未返回图片地址:" + brief(responseBody)); + } + + /** + * 解析 part 的 Content-Type:优先用客户端声明的图片类型,否则用 Tika 按内容嗅探。 + * + *

不能让证件图以 octet-stream 落到 OSS —— 见类注释。

+ */ + static String resolveContentType(MultipartFile file, byte[] bytes, String filename) { + String declared = file.getContentType(); + if (declared != null && declared.startsWith("image/")) { + return declared; + } + try { + String detected = TIKA.detect(bytes, filename); + if (StrUtil.isNotBlank(detected) && detected.startsWith("image/")) { + return detected; + } + } catch (Exception e) { + log.debug("HjcUpload: 嗅探文件类型失败 name={}", filename, e); + } + return StrUtil.isNotBlank(declared) ? declared : "application/octet-stream"; + } + + private static boolean isHttpUrl(String value) { + return value != null && (value.startsWith("http://") || value.startsWith("https://")); + } + + private static String brief(String text) { + if (text == null) { + return null; + } + return text.length() <= 200 ? text : text.substring(0, 200) + "..."; + } +} diff --git a/src/test/java/com/gxwebsoft/hjc/util/HjcOssUploadUtilTest.java b/src/test/java/com/gxwebsoft/hjc/util/HjcOssUploadUtilTest.java new file mode 100644 index 0000000..1f5f49d --- /dev/null +++ b/src/test/java/com/gxwebsoft/hjc/util/HjcOssUploadUtilTest.java @@ -0,0 +1,198 @@ +package com.gxwebsoft.hjc.util; + +import com.gxwebsoft.common.core.config.ConfigProperties; +import okhttp3.MediaType; +import okhttp3.MultipartBody; +import okhttp3.Request; +import okhttp3.RequestBody; +import okio.Buffer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 证件上传(委托统一 OSS 上传服务)单测:不联网,覆盖请求形状与响应解析。 + * + *

与百度云 OCR 客户端同一套路:唯一的 HTTP 出口 {@link HjcOssUploadUtil#execute(Request)} 被覆盖成打桩。

+ */ +class HjcOssUploadUtilTest { + + private static final String SERVER_URL = "https://server.websoft.top/api"; + + private static final String OK_RESPONSE = + "{\"code\":0,\"message\":\"操作成功\",\"data\":{" + + "\"id\":85363,\"name\":\"license.jpg\",\"length\":51786," + + "\"path\":\"https://oss.wsdns.cn/20260910/0a5e8112405243c6b8071eaff5e4971e.jpg\"," + + "\"url\":\"https://oss.wsdns.cn/20260910/0a5e8112405243c6b8071eaff5e4971e.jpg?x-oss-process=image/resize,w_300\"" + + "}}"; + + private StubUploader uploader; + + @BeforeEach + void setUp() { + uploader = new StubUploader(); + ConfigProperties configProperties = new ConfigProperties(); + ReflectionTestUtils.setField(configProperties, "serverUrl", SERVER_URL); + ReflectionTestUtils.setField(uploader, "configProperties", configProperties); + } + + @Test + void 上传成功返回原图地址() throws IOException { + uploader.response = OK_RESPONSE; + + String url = uploader.upload(jpeg("license.jpg"), "10626"); + + assertEquals("https://oss.wsdns.cn/20260910/0a5e8112405243c6b8071eaff5e4971e.jpg", url); + } + + @Test + void 请求形状正确() throws IOException { + uploader.response = OK_RESPONSE; + + uploader.upload(jpeg("license.jpg"), "10626"); + + Request request = uploader.lastRequest; + assertEquals(SERVER_URL + "/oss/upload", request.url().toString()); + assertEquals("POST", request.method()); + assertEquals("10626", request.header("tenantId")); + // 不转发调用方的 Authorization:hjc 的 JWT 与核心后端不是同一套签名,转过去只会被 401 + assertEquals(null, request.header("Authorization")); + + RequestBody body = request.body(); + assertTrue(body.contentType().toString().startsWith("multipart/form-data")); + MultipartBody multipart = (MultipartBody) body; + assertEquals(1, multipart.parts().size()); + MultipartBody.Part part = multipart.parts().get(0); + // okhttp 4 的 Part 没有 name()/fileName(),从 Content-Disposition 取 + String disposition = part.headers().get("Content-Disposition"); + assertTrue(disposition.contains("name=\"file\""), disposition); + assertTrue(disposition.contains("filename=\"license.jpg\""), disposition); + // part 的类型必须保留为图片:该服务会把它写成 OSS 对象元数据, + // 否则浏览器变下载、且 OCR 取图的 image/* 校验会拒掉自己上传的图 + assertEquals("image/jpeg", part.body().contentType().toString()); + Buffer buffer = new Buffer(); + part.body().writeTo(buffer); + assertTrue(buffer.readByteArray().length > 0); + } + + @Test + void 没有tenantId时不带该请求头() throws IOException { + uploader.response = OK_RESPONSE; + + uploader.upload(jpeg("license.jpg"), null); + + assertEquals(null, uploader.lastRequest.header("tenantId")); + } + + @Test + void 只有带缩略参数的url时返回去掉参数的原图地址() throws IOException { + uploader.response = "{\"code\":0,\"data\":{\"url\":\"https://oss.wsdns.cn/a/b.jpg?x-oss-process=image/resize,w_300\"}}"; + + String url = uploader.upload(jpeg("license.jpg"), "10626"); + + assertEquals("https://oss.wsdns.cn/a/b.jpg", url); + } + + @Test + void 服务返回失败码时抛异常并带出message() { + uploader.response = "{\"code\":401,\"message\":\"请退出重新登录\",\"error\":\"MalformedJwtException\"}"; + + IOException e = assertThrows(IOException.class, () -> uploader.upload(jpeg("license.jpg"), "10626")); + + assertTrue(e.getMessage().contains("请退出重新登录")); + assertTrue(e.getMessage().contains("401") || e.getMessage().contains("失败")); + } + + @Test + void 响应不是JSON时抛异常() { + uploader.response = "502 Bad Gateway"; + + IOException e = assertThrows(IOException.class, () -> uploader.upload(jpeg("license.jpg"), "10626")); + + assertTrue(e.getMessage().contains("不是合法 JSON")); + } + + @Test + void 未返回地址时抛异常() { + uploader.response = "{\"code\":0,\"message\":\"操作成功\",\"data\":{\"id\":1,\"name\":\"a.jpg\"}}"; + + IOException e = assertThrows(IOException.class, () -> uploader.upload(jpeg("license.jpg"), "10626")); + + assertTrue(e.getMessage().contains("未返回图片地址")); + } + + @Test + void part类型优先用客户端声明的图片类型() { + MockMultipartFile file = new MockMultipartFile("file", "a.png", "image/png", new byte[]{1, 2, 3}); + + assertEquals("image/png", HjcOssUploadUtil.resolveContentType(file, fileBytes(file), "a.png")); + } + + @Test + void 客户端没声明图片类型时用嗅探() { + // 只声明成 octet-stream(部分客户端如此),靠内容嗅探出 image/jpeg + MockMultipartFile file = new MockMultipartFile("file", "a.jpg", "application/octet-stream", + jpegBytes()); + + assertEquals("image/jpeg", HjcOssUploadUtil.resolveContentType(file, fileBytes(file), "a.jpg")); + } + + @Test + void 既没声明也嗅探不出时回退octet_stream() { + MockMultipartFile file = new MockMultipartFile("file", "a.bin", null, + "not-an-image".getBytes(StandardCharsets.UTF_8)); + + String contentType = HjcOssUploadUtil.resolveContentType(file, fileBytes(file), "a.bin"); + + assertFalse(contentType.startsWith("image/")); + } + + // ---------- 测试替身与夹具 ---------- + + private static class StubUploader extends HjcOssUploadUtil { + + private String response; + private Request lastRequest; + + @Override + protected String execute(Request request) throws IOException { + this.lastRequest = request; + return response; + } + } + + private static MockMultipartFile jpeg(String filename) { + return new MockMultipartFile("file", filename, "image/jpeg", jpegBytes()); + } + + /** 最小 JPEG 头(FFD8FFE0 + JFIF),供 Tika 嗅探 */ + private static byte[] jpegBytes() { + List bytes = new ArrayList<>(); + int[] head = {0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, + 0x00, 0x01, 0x00, 0x00, 0xFF, 0xD9}; + byte[] result = new byte[head.length]; + for (int i = 0; i < head.length; i++) { + result[i] = (byte) head[i]; + } + return result; + } + + private static byte[] fileBytes(MockMultipartFile file) { + try { + return file.getBytes(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } +}