20260802220544

This commit is contained in:
oneao committed 2026-08-02 22:05:44 +08:00
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