fix(hjc): 证件上传改为存入 OSS(此前只写本地盘、返回的地址打不开)

问题:上传接口只把文件写进本地磁盘,再把地址拼成 {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/无地址、类型嗅探)。
This commit is contained in:
2026-09-10 14:47:06 +08:00
parent 1e2ec82c18
commit 5d88684820
3 changed files with 379 additions and 24 deletions
@@ -1,13 +1,10 @@
package com.gxwebsoft.hjc.controller; package com.gxwebsoft.hjc.controller;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.gxwebsoft.common.core.config.ConfigProperties; import com.gxwebsoft.common.core.config.ConfigProperties;
import com.gxwebsoft.common.core.security.JwtSubject; import com.gxwebsoft.common.core.security.JwtSubject;
import com.gxwebsoft.common.core.security.JwtUtil; 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.ApiResult;
import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.system.entity.Role; 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.entity.HjcEnterpriseMaterial;
import com.gxwebsoft.hjc.service.HjcEnterpriseMaterialService; import com.gxwebsoft.hjc.service.HjcEnterpriseMaterialService;
import com.gxwebsoft.hjc.service.HjcEnterpriseService; 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.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; 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 org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.io.File;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -43,6 +41,7 @@ import java.util.Map;
* 汇吉采 网站企业 注册/登录(企业名称+密码) * 汇吉采 网站企业 注册/登录(企业名称+密码)
*/ */
@Tag(name = "汇吉采-企业登录") @Tag(name = "汇吉采-企业登录")
@Slf4j
@RestController @RestController
@RequestMapping("/api/hjc/auth") @RequestMapping("/api/hjc/auth")
public class HjcAuthController extends BaseController { public class HjcAuthController extends BaseController {
@@ -60,43 +59,30 @@ public class HjcAuthController extends BaseController {
@Resource @Resource
private HjcEnterpriseMaterialService hjcEnterpriseMaterialService; private HjcEnterpriseMaterialService hjcEnterpriseMaterialService;
@Resource @Resource
private RedisUtil redisUtil; private HjcOssUploadUtil hjcOssUploadUtil;
/** 注册必传的资质证件材料类型 */ /** 注册必传的资质证件材料类型 */
private static final List<String> REQUIRED_MATERIAL_TYPES = private static final List<String> REQUIRED_MATERIAL_TYPES =
Arrays.asList("idcard_front", "idcard_back", "handbook", "license"); Arrays.asList("idcard_front", "idcard_back", "handbook", "license");
@Operation(summary = "注册证件上传(匿名,注册页专用,不写文件表") @Operation(summary = "注册证件上传(匿名,注册页与资质页通用;文件存入 OSS")
@PostMapping("/upload") @PostMapping("/upload")
public ApiResult<Map<String, Object>> upload(@RequestParam("file") MultipartFile file) { public ApiResult<Map<String, Object>> upload(@RequestParam("file") MultipartFile file) {
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
return fail("上传文件不能为空", null); return fail("上传文件不能为空", null);
} }
try { 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(); Integer tenantId = getTenantId();
if (tenantId != null) { // 委托统一 OSS 上传服务(server.websoft.top/api/oss/upload):它会按租户取云存储配置、
JSONObject setting = JSONObject.parseObject(redisUtil.get("setting:upload:" + tenantId)); // 把文件写进 OSS 并返回可访问地址。此前这里只写本地磁盘再拼 OSS 域名,文件从未进过 OSS。
if (setting != null) { String url = hjcOssUploadUtil.upload(file, tenantId == null ? null : tenantId.toString());
String uploadMethod = setting.getString("uploadMethod");
String bucketDomain = setting.getString("bucketDomain");
if (StrUtil.isNotBlank(uploadMethod) && !"file".equals(uploadMethod)
&& StrUtil.isNotBlank(bucketDomain)) {
url = bucketDomain + path;
}
}
}
Map<String, Object> data = new HashMap<>(4); Map<String, Object> data = new HashMap<>(4);
data.put("url", url); data.put("url", url);
data.put("name", file.getOriginalFilename()); data.put("name", file.getOriginalFilename());
return success(data); return success(data);
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); log.warn("HjcUpload: 证件上传失败 tenantId={} name={} size={}",
getTenantId(), file.getOriginalFilename(), file.getSize(), e);
return fail("上传失败", null); return fail("上传失败", null);
} }
} }
@@ -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 上传服务。
*
* <p><b>为什么是「委托」而不是自己 putObject</b>:平台里已有一个可用的 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 是给别的用途的,不该在这里重复消费)。</p>
*
* <p><b>不转发调用方的 Authorization</b>:该服务实测匿名即可用(只认 {@code tenantId} 头),
* 而 hjc 前端的 JWT 与核心后端不是同一套签名,转发过去只会被判 401。故只带 {@code tenantId}。</p>
*
* <p><b>multipart 的 part 类型要正确</b>:该服务把上传 part 的 Content-Type 直接写成 OSS 对象元数据
* (实测:part 传 image/jpegOSS 返回的 Content-Type 就是 image/jpeg)。若传成
* application/octet-stream,浏览器会变成下载、且 {@code HjcOcrImageFetcher} 的 {@code image/*} 校验会把
* 自家上传的证件图拒掉,因此这里显式解析/嗅探图片类型。</p>
*/
@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=…"}}}
*
* <p>优先取 {@code data.path}{@code data.url} 会带 {@code ?x-oss-process} 缩略参数,
* 而证件要原图(缩略会影响证件识别的清晰度)。</p>
*/
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 按内容嗅探。
*
* <p>不能让证件图以 octet-stream 落到 OSS —— 见类注释。</p>
*/
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) + "...";
}
}
@@ -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 上传服务)单测:不联网,覆盖请求形状与响应解析。
*
* <p>与百度云 OCR 客户端同一套路:唯一的 HTTP 出口 {@link HjcOssUploadUtil#execute(Request)} 被覆盖成打桩。</p>
*/
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"));
// 不转发调用方的 Authorizationhjc 的 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 = "<html>502 Bad Gateway</html>";
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<Byte> 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);
}
}
}