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;
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<String> REQUIRED_MATERIAL_TYPES =
Arrays.asList("idcard_front", "idcard_back", "handbook", "license");
@Operation(summary = "注册证件上传(匿名,注册页专用,不写文件表")
@Operation(summary = "注册证件上传(匿名,注册页与资质页通用;文件存入 OSS")
@PostMapping("/upload")
public ApiResult<Map<String, Object>> 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<String, Object> 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);
}
}
@@ -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) + "...";
}
}