20260802220544
This commit is contained in:
1 parent
c00329b5a4
commit
4fc9040a8a
60 files changed
+6273
-908
No files matched your search
@@ -0,0 +1,68 @@
|
||||
package cn.g3soft.fmsapi.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "fms.file")
|
||||
public class FileStorageProperties {
|
||||
|
||||
private String storageType;
|
||||
private Local local = new Local();
|
||||
private Oss oss = new Oss();
|
||||
|
||||
public String getStorageType() {
|
||||
return storageType;
|
||||
}
|
||||
|
||||
public void setStorageType(String storageType) {
|
||||
this.storageType = storageType;
|
||||
}
|
||||
|
||||
public Local getLocal() {
|
||||
return local;
|
||||
}
|
||||
|
||||
public void setLocal(Local local) {
|
||||
this.local = local;
|
||||
}
|
||||
|
||||
public Oss getOss() {
|
||||
return oss;
|
||||
}
|
||||
|
||||
public void setOss(Oss oss) {
|
||||
this.oss = oss;
|
||||
}
|
||||
|
||||
public static class Local {
|
||||
private String storageDir;
|
||||
private String urlPrefix;
|
||||
|
||||
public String getStorageDir() {
|
||||
return storageDir;
|
||||
}
|
||||
|
||||
public void setStorageDir(String storageDir) {
|
||||
this.storageDir = storageDir;
|
||||
}
|
||||
|
||||
public String getUrlPrefix() {
|
||||
return urlPrefix;
|
||||
}
|
||||
|
||||
public void setUrlPrefix(String urlPrefix) {
|
||||
this.urlPrefix = urlPrefix;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Oss {
|
||||
private String g3ossUrl;
|
||||
|
||||
public String getG3ossUrl() {
|
||||
return g3ossUrl;
|
||||
}
|
||||
|
||||
public void setG3ossUrl(String g3ossUrl) {
|
||||
this.g3ossUrl = g3ossUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
}
|
||||
|
||||
OrgContext.setOrgId(claims.orgId());
|
||||
OrgContext.setUserId(claims.userId());
|
||||
try {
|
||||
filterChain.doFilter(request, response);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package cn.g3soft.fmsapi.controller;
|
||||
|
||||
import cn.g3soft.fmsapi.config.FileStorageProperties;
|
||||
import cn.g3soft.fmsapi.service.FileService;
|
||||
import cn.g3soft.fmsapi.utils.ApiResponse;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.SQLException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/file")
|
||||
public class FileController {
|
||||
|
||||
@Resource
|
||||
private FileStorageProperties props;
|
||||
|
||||
@Resource
|
||||
private FileService fileService;
|
||||
|
||||
/**
|
||||
* 返回存储模式配置
|
||||
*/
|
||||
@PostMapping("/config")
|
||||
public ApiResponse<Map<String, Object>> config() {
|
||||
Map<String, Object> cfg = new LinkedHashMap<>();
|
||||
cfg.put("storageType", props.getStorageType().toLowerCase());
|
||||
return ApiResponse.success(cfg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地上传(支持多文件,FormData key: "files")
|
||||
*/
|
||||
@PostMapping("/upload")
|
||||
public ApiResponse<List<Map<String, Object>>> upload(
|
||||
@RequestParam("files") List<MultipartFile> files,
|
||||
@RequestParam("father") String father,
|
||||
@RequestParam("moduleId") String moduleId,
|
||||
@RequestParam(value = "cateId", required = false) String cateId
|
||||
) throws IOException, SQLException {
|
||||
return ApiResponse.success(fileService.localUpload(files, father, moduleId, cateId));
|
||||
}
|
||||
|
||||
/**
|
||||
* OSS 上传凭证(bucket = 机构码)
|
||||
*/
|
||||
@PostMapping("/upload-credential")
|
||||
public ApiResponse<?> uploadCredential() throws IOException, InterruptedException {
|
||||
return ApiResponse.success(fileService.ossCredential());
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存文件记录到 DB(OSS 直传后回调)
|
||||
*/
|
||||
@PostMapping("/save-record")
|
||||
public ApiResponse<Map<String, Object>> saveRecord(@RequestBody Map<String, Object> body)
|
||||
throws SQLException {
|
||||
return ApiResponse.success(fileService.saveRecord(body));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*/
|
||||
@PostMapping("/delete")
|
||||
public ApiResponse<Void> delete(@RequestBody Map<String, Object> body)
|
||||
throws SQLException, IOException, InterruptedException {
|
||||
String subid = String.valueOf(body.get("subid"));
|
||||
fileService.delete(subid);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地文件访问(路径含子目录: {orgId}/{yyyy}/{MM}/{dd}/{file})
|
||||
*/
|
||||
@GetMapping("/static/{fileName:.+}")
|
||||
public void serveFile(@PathVariable String fileName, HttpServletResponse response) throws IOException {
|
||||
Path dir = Path.of(props.getLocal().getStorageDir()).toAbsolutePath().normalize();
|
||||
Path file = dir.resolve(fileName).normalize();
|
||||
|
||||
// 路径穿越防护
|
||||
if (!file.startsWith(dir) || !Files.isRegularFile(file)) {
|
||||
response.setStatus(404);
|
||||
return;
|
||||
}
|
||||
|
||||
response.setContentType("application/octet-stream");
|
||||
response.setHeader("Content-Disposition", "inline; filename*=UTF-8''"
|
||||
+ URLEncoder.encode(fileName, StandardCharsets.UTF_8));
|
||||
response.setContentLengthLong(Files.size(file));
|
||||
|
||||
try (OutputStream out = response.getOutputStream()) {
|
||||
Files.copy(file, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@ package cn.g3soft.fmsapi.database;
|
||||
public final class OrgContext {
|
||||
|
||||
private static final ThreadLocal<String> ORG_ID_HOLDER = new ThreadLocal<>();
|
||||
private static final ThreadLocal<String> USER_ID_HOLDER = new ThreadLocal<>();
|
||||
|
||||
private OrgContext() {
|
||||
}
|
||||
|
||||
// -- orgId --
|
||||
|
||||
public static void setOrgId(String orgId) {
|
||||
if (orgId == null || orgId.isBlank()) {
|
||||
throw new IllegalArgumentException("机构码不能为空");
|
||||
@@ -26,7 +29,26 @@ public final class OrgContext {
|
||||
return orgId;
|
||||
}
|
||||
|
||||
// -- userId --
|
||||
|
||||
public static void setUserId(String userId) {
|
||||
USER_ID_HOLDER.set(userId);
|
||||
}
|
||||
|
||||
public static String getUserId() {
|
||||
return USER_ID_HOLDER.get();
|
||||
}
|
||||
|
||||
public static String requireUserId() {
|
||||
String userId = getUserId();
|
||||
if (userId == null) {
|
||||
throw new IllegalStateException("当前请求未设置用户上下文");
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
ORG_ID_HOLDER.remove();
|
||||
USER_ID_HOLDER.remove();
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,12 @@ public class OrgDataSourceFactory {
|
||||
dataSource.setTestWhileIdle(poolProperties.isTestWhileIdle());
|
||||
dataSource.setTestOnBorrow(poolProperties.isTestOnBorrow());
|
||||
dataSource.setTestOnReturn(poolProperties.isTestOnReturn());
|
||||
dataSource.setConnectionErrorRetryAttempts(0);
|
||||
dataSource.setBreakAfterAcquireFailure(true);
|
||||
dataSource.setFailFast(true);
|
||||
dataSource.setConnectionErrorRetryAttempts(3);
|
||||
dataSource.setBreakAfterAcquireFailure(false);
|
||||
dataSource.setFailFast(false);
|
||||
// 定期 ping 空闲连接,对抗云 NAT/防火墙回收空闲 TCP 会话(默认 120s)
|
||||
dataSource.setKeepAlive(true);
|
||||
dataSource.setKeepAliveBetweenTimeMillis(120_000);
|
||||
dataSource.setTimeBetweenEvictionRunsMillis(
|
||||
poolProperties.getTimeBetweenEvictionRuns().toMillis()
|
||||
);
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package cn.g3soft.fmsapi.service;
|
||||
|
||||
import cn.g3soft.fmsapi.config.FileStorageProperties;
|
||||
import cn.g3soft.fmsapi.database.OrgContext;
|
||||
import cn.g3soft.fmsapi.exception.BusinessException;
|
||||
import cn.g3soft.fmsapi.utils.DbUtils;
|
||||
import cn.g3soft.fmsapi.utils.snowflake.idgen.IdGenerator;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.jdbc.datasource.DataSourceUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class FileService {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
@Resource
|
||||
private FileStorageProperties props;
|
||||
|
||||
@Resource
|
||||
private DbUtils dbUtils;
|
||||
|
||||
@Resource
|
||||
private DataSource dataSource;
|
||||
|
||||
/**
|
||||
* 本地上传:逐个写磁盘 → 写 DB → 返回元数据列表
|
||||
*/
|
||||
public List<Map<String, Object>> localUpload(List<MultipartFile> files, String father, String moduleId, String cateId)
|
||||
throws IOException, SQLException {
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
for (MultipartFile file : files) {
|
||||
results.add(uploadOne(file, father, moduleId, cateId));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private Map<String, Object> uploadOne(MultipartFile file, String father, String moduleId, String cateId)
|
||||
throws IOException, SQLException {
|
||||
long subid = IdGenerator.nextId();
|
||||
String originalName = file.getOriginalFilename();
|
||||
String ext = extension(originalName);
|
||||
String fileName = UUID.randomUUID().toString().replace("-", "") + (ext.isEmpty() ? "" : "." + ext);
|
||||
|
||||
// 机构码/年/月/日/文件名
|
||||
String orgId = OrgContext.requireOrgId();
|
||||
LocalDate today = LocalDate.now();
|
||||
String subPath = String.format("%s/%04d/%02d/%02d/%s",
|
||||
orgId, today.getYear(), today.getMonthValue(), today.getDayOfMonth(), fileName);
|
||||
|
||||
Path baseDir = Path.of(props.getLocal().getStorageDir()).toAbsolutePath().normalize();
|
||||
Path target = baseDir.resolve(subPath);
|
||||
Files.createDirectories(target.getParent());
|
||||
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
String mapFilename = props.getLocal().getUrlPrefix() + "/" + subPath;
|
||||
|
||||
insertFileRecord(subid, father, moduleId, cateId,
|
||||
originalName, String.valueOf(file.getSize()), ext, mapFilename);
|
||||
|
||||
Map<String, Object> meta = new LinkedHashMap<>();
|
||||
meta.put("subid", String.valueOf(subid));
|
||||
meta.put("mx_filename", originalName);
|
||||
meta.put("mx_filesize", String.valueOf(file.getSize()));
|
||||
meta.put("mx_fileext", ext);
|
||||
meta.put("mx_mapfilename", mapFilename);
|
||||
meta.put("father", father);
|
||||
meta.put("mx_moduleid", moduleId);
|
||||
meta.put("mx_cate_id", cateId);
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存文件记录到 DB(OSS 模式:前端直传后回调写入)
|
||||
*/
|
||||
public Map<String, Object> saveRecord(Map<String, Object> meta) throws SQLException {
|
||||
long subid = Long.parseLong(String.valueOf(meta.get("subid")));
|
||||
String father = String.valueOf(meta.get("father"));
|
||||
String moduleId = String.valueOf(meta.get("mx_moduleid"));
|
||||
String cateId = meta.containsKey("mx_cate_id") ? String.valueOf(meta.get("mx_cate_id")) : null;
|
||||
String filename = String.valueOf(meta.get("mx_filename"));
|
||||
String filesize = String.valueOf(meta.get("mx_filesize"));
|
||||
String fileext = String.valueOf(meta.get("mx_fileext"));
|
||||
String mapfilename = String.valueOf(meta.get("mx_mapfilename"));
|
||||
|
||||
insertFileRecord(subid, father, moduleId, cateId,
|
||||
filename, filesize, fileext, mapfilename);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("subid", String.valueOf(subid));
|
||||
result.put("message", "记录已保存");
|
||||
return result;
|
||||
}
|
||||
|
||||
private void insertFileRecord(long subid, String father, String moduleId, String cateId,
|
||||
String filename, String filesize, String fileext, String mapfilename)
|
||||
throws SQLException {
|
||||
String userId = OrgContext.getUserId();
|
||||
Connection connection = DataSourceUtils.getConnection(dataSource);
|
||||
try {
|
||||
String sql = """
|
||||
INSERT INTO [bf_files] (
|
||||
[subid], [father], [mx_filename], [mx_filesize], [mx_fileext],
|
||||
[mx_mapfilename], [mx_moduleid], [mx_cate_id], [mx_xh], [mx_sh],
|
||||
[mx_inputuser_id], [mx_inputdatetime]
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, SYSDATETIME())
|
||||
""";
|
||||
try (PreparedStatement ps = connection.prepareStatement(sql)) {
|
||||
ps.setLong(1, subid);
|
||||
ps.setObject(2, toLongOrNull(father));
|
||||
ps.setString(3, filename);
|
||||
ps.setString(4, filesize);
|
||||
ps.setString(5, fileext);
|
||||
ps.setString(6, mapfilename);
|
||||
ps.setObject(7, toLongOrNull(moduleId));
|
||||
ps.setObject(8, toLongOrNull(cateId));
|
||||
ps.setObject(9, toLongOrNull(userId));
|
||||
ps.executeUpdate();
|
||||
}
|
||||
} finally {
|
||||
DataSourceUtils.releaseConnection(connection, dataSource);
|
||||
}
|
||||
}
|
||||
|
||||
private static Long toLongOrNull(String s) {
|
||||
if (s == null || s.isBlank()) return null;
|
||||
try {
|
||||
return Long.parseLong(s);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OSS 凭证:调 g3oss,bucket = 当前机构码
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> ossCredential() throws IOException, InterruptedException {
|
||||
String orgId = OrgContext.requireOrgId();
|
||||
String url = props.getOss().getG3ossUrl() + "/upload?orgid=" + orgId + "&bucket=" + orgId;
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new BusinessException("获取 OSS 上传凭证失败: " + response.statusCode());
|
||||
}
|
||||
|
||||
return OBJECT_MAPPER.readValue(response.body(), Map.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件:查 bf_files → 删存储 → 删 DB 行
|
||||
*/
|
||||
public void delete(String subid) throws SQLException, IOException, InterruptedException {
|
||||
List<Map<String, Object>> rows = dbUtils.loadData("bf_files", "subid = " + subid, null, null);
|
||||
if (rows.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String mapfilename = (String) rows.get(0).get("mx_mapfilename");
|
||||
|
||||
// 删存储文件
|
||||
if ("aliyun-oss".equalsIgnoreCase(props.getStorageType())) {
|
||||
deleteOssFile(mapfilename);
|
||||
} else {
|
||||
deleteLocalFile(mapfilename);
|
||||
}
|
||||
|
||||
// 删 DB 行
|
||||
deleteDbRow(subid);
|
||||
}
|
||||
|
||||
private void deleteLocalFile(String mapfilename) throws IOException {
|
||||
if (mapfilename == null) return;
|
||||
// mapfilename: /api/file/static/{orgId}/{yyyy}/{MM}/{dd}/{file}
|
||||
String prefix = props.getLocal().getUrlPrefix() + "/";
|
||||
if (!mapfilename.startsWith(prefix)) return;
|
||||
String relativePath = mapfilename.substring(prefix.length());
|
||||
Path file = Path.of(props.getLocal().getStorageDir()).toAbsolutePath().normalize().resolve(relativePath);
|
||||
Files.deleteIfExists(file);
|
||||
}
|
||||
|
||||
private void deleteOssFile(String mapfilename) throws IOException, InterruptedException {
|
||||
if (mapfilename == null || mapfilename.isBlank()) {
|
||||
return;
|
||||
}
|
||||
// 从 URL 提取 objectName(bucket 直接用机构码)
|
||||
String objectName = mapfilename;
|
||||
if (objectName.contains("://")) {
|
||||
objectName = objectName.substring(objectName.indexOf("://") + 3);
|
||||
}
|
||||
// 跳过 host + bucket 两段
|
||||
int firstSlash = objectName.indexOf('/');
|
||||
if (firstSlash > 0) {
|
||||
objectName = objectName.substring(firstSlash + 1);
|
||||
int secondSlash = objectName.indexOf('/');
|
||||
if (secondSlash > 0) {
|
||||
objectName = objectName.substring(secondSlash + 1);
|
||||
}
|
||||
}
|
||||
|
||||
String bucket = OrgContext.requireOrgId();
|
||||
String url = props.getOss().getG3ossUrl()
|
||||
+ "/delete?bucketName=" + bucket + "&objectName=" + objectName;
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new BusinessException("OSS 删除文件失败: " + response.statusCode());
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteDbRow(String subid) throws SQLException {
|
||||
Connection connection = DataSourceUtils.getConnection(dataSource);
|
||||
try {
|
||||
String sql = "DELETE FROM [bf_files] WHERE [subid] = ?";
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, Long.parseLong(subid));
|
||||
statement.executeUpdate();
|
||||
}
|
||||
} finally {
|
||||
DataSourceUtils.releaseConnection(connection, dataSource);
|
||||
}
|
||||
}
|
||||
|
||||
private String extension(String name) {
|
||||
if (name == null || !name.contains(".")) {
|
||||
return "";
|
||||
}
|
||||
return name.substring(name.lastIndexOf('.') + 1).toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,14 @@ fms:
|
||||
max-wait: 5000
|
||||
validation-query: SELECT 1
|
||||
test-while-idle: true
|
||||
test-on-borrow: false
|
||||
test-on-borrow: true
|
||||
test-on-return: false
|
||||
time-between-eviction-runs: 60s
|
||||
min-evictable-idle-time: 5m
|
||||
time-between-eviction-runs: 30s
|
||||
min-evictable-idle-time: 30s
|
||||
file:
|
||||
storage-type: local
|
||||
local:
|
||||
storage-dir: ./data/files
|
||||
url-prefix: /api/file/static
|
||||
oss:
|
||||
g3oss-url: http://g3oss.g3soft.cn:8082
|
||||
Reference in new issue
Block a user