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:
@@ -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/jpeg,OSS 返回的 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) + "...";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user