init: 项目初始化

This commit is contained in:
2026-04-22 14:11:54 +08:00
commit 2717dcf58b
8 changed files with 615 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
target/
!.mvn/wrapper/maven-wrapper.jar
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
*.log
*.flattened-pom.xml
### NetBeans ###
nbproject/private/
build/
nbbuild/
dist/
nbdist/
.nb-gradle/
### Mac files ###
*.DS_Store
+109
View File
@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.noear</groupId>
<artifactId>solon-parent</artifactId>
<version>3.10.0</version>
<relativePath />
</parent>
<groupId>com.yxtech</groupId>
<artifactId>ossutil</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<description>watch valid</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<!-- AIO 服务器,高并发低开销 -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-boot-smarthttp</artifactId>
</dependency>
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-logging-logback-jakarta</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-test</artifactId>
<scope>test</scope>
</dependency>
<!-- HTTP 客户端,用于调用 IOSP OSS 接口 -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.14</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpmime</artifactId>
<version>4.5.14</version>
</dependency>
<!-- YAML 配置支持 -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-config-yaml</artifactId>
</dependency>
<!-- JSON 解析 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.53</version>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.noear</groupId>
<artifactId>solon-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>tencent</id>
<url>https://mirrors.cloud.tencent.com/nexus/repository/maven-public/</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
</project>
+11
View File
@@ -0,0 +1,11 @@
package com.yxtech.ossutil;
import org.noear.solon.Solon;
import org.noear.solon.annotation.SolonMain;
@SolonMain
public class App {
public static void main(String[] args) {
Solon.start(App.class, args);
}
}
@@ -0,0 +1,44 @@
package com.yxtech.ossutil.config;
import com.yxtech.ossutil.util.XjOssUtil;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Configuration;
import org.noear.solon.annotation.Init;
import org.noear.solon.annotation.Inject;
/**
* IOSP OSS 配置,从 app.yml 读取并注入到 XjOssUtil 静态字段
*/
@Slf4j
@Configuration
public class XjOssConfig {
@Inject("${xj.oss.gateway-url}")
private String gatewayUrl;
@Inject("${xj.oss.client-id}")
private String clientId;
@Inject("${xj.oss.client-secret}")
private String clientSecret;
@Inject("${xj.oss.organization-id}")
private String organizationId;
@Inject("${xj.oss.config-code}")
private String configCode;
@Inject("${xj.oss.directory}")
private String directory;
/** 逗号分隔的扩展名白名单,如 jpg,png,pdf;为空则不限制 */
@Inject("${xj.oss.file-type-white-list:}")
private String fileTypeWhiteList;
@Init
public void init() {
XjOssUtil.setGatewayUrl(gatewayUrl);
XjOssUtil.setClientId(clientId);
XjOssUtil.setClientSecret(clientSecret);
XjOssUtil.setOrganizationId(organizationId);
XjOssUtil.setConfigCode(configCode);
XjOssUtil.setDirectory(directory);
XjOssUtil.setFileTypeWhiteList(fileTypeWhiteList);
log.info("[XjOssConfig] 初始化完成,网关: {},白名单: {}", gatewayUrl,
fileTypeWhiteList.isBlank() ? "不限制" : fileTypeWhiteList);
}
}
@@ -0,0 +1,127 @@
package com.yxtech.ossutil.controller;
import com.yxtech.ossutil.util.XjOssUtil;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Controller;
import org.noear.solon.annotation.Get;
import org.noear.solon.annotation.Mapping;
import org.noear.solon.core.handle.Context;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* IOSP OSS 文件下载与预览
* - /sys/upload/down/** 强制下载
* - /sys/upload/show/** 浏览器内联预览
*/
@Slf4j
@Controller
@Mapping("/")
public class SysUploadController {
@Get
@Mapping("/down/**")
public void fileDown(Context ctx) throws Exception {
serve(ctx, "/down/", true);
}
@Get
@Mapping("/show/**")
public void fileShow(Context ctx) throws Exception {
serve(ctx, "/show/", false);
}
// ---- 核心逻辑 ----
private void serve(Context ctx, String marker, boolean download) throws Exception {
String fileKey = extractFileKey(ctx, marker);
if (isIllegalFileKey(fileKey)) {
ctx.status(400);
ctx.output("fileKey 格式非法");
return;
}
String fileName = fileKey.contains("@") ? fileKey.substring(fileKey.indexOf("@") + 1) : fileKey;
String ext = ext(fileName);
// 白名单校验
try {
XjOssUtil.checkExt(ext);
} catch (IllegalArgumentException e) {
ctx.status(403);
ctx.output(e.getMessage());
return;
}
try (InputStream in = XjOssUtil.down(fileKey)) {
if (in == null) {
ctx.status(404);
ctx.output("文件不存在或下载失败");
return;
}
String disposition = download ? "attachment" : "inline";
ctx.contentType(download ? "application/octet-stream" : resolveContentType(ext));
ctx.headerSet("Content-Disposition",
disposition + ";filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8));
try (OutputStream out = ctx.outputStream()) {
byte[] buf = new byte[8192];
int len;
while ((len = in.read(buf)) != -1) out.write(buf, 0, len);
out.flush();
}
} catch (Exception e) {
log.error("[SysUploadController] 文件服务失败, fileKey={}, download={}", fileKey, download, e);
}
}
// ---- 工具方法 ----
private String extractFileKey(Context ctx, String marker) {
String path = ctx.path();
int idx = path.indexOf(marker);
if (idx < 0) return "";
return URLDecoder.decode(path.substring(idx + marker.length()), StandardCharsets.UTF_8);
}
private boolean isIllegalFileKey(String fileKey) {
if (fileKey == null || fileKey.isBlank()) return true;
// 长度限制,防止资源耗尽
if (fileKey.length() > 512) return true;
// null byte 注入(可绕过扩展名校验)
if (fileKey.contains("\0")) return true;
// 绝对路径
if (fileKey.startsWith("/") || fileKey.startsWith("\\")) return true;
// 路径穿透:同时检查原始值和二次解码值(防双重编码绕过)
String decoded = URLDecoder.decode(fileKey, StandardCharsets.UTF_8);
return hasTraversal(fileKey) || hasTraversal(decoded);
}
private boolean hasTraversal(String s) {
return s.contains("../") || s.contains("..\\") || s.startsWith("..") || s.contains("/..");
}
private String ext(String fileName) {
int dot = fileName.lastIndexOf('.');
return dot >= 0 ? fileName.substring(dot + 1).toLowerCase() : "";
}
private String resolveContentType(String ext) {
return switch (ext) {
case "jpg", "jpeg" -> "image/jpeg";
case "png" -> "image/png";
case "gif" -> "image/gif";
case "webp" -> "image/webp";
case "bmp" -> "image/bmp";
case "svg" -> "image/svg+xml";
case "pdf" -> "application/pdf";
case "mp4" -> "video/mp4";
case "mp3" -> "audio/mpeg";
case "txt" -> "text/plain;charset=UTF-8";
case "html", "htm" -> "text/html;charset=UTF-8";
default -> "application/octet-stream";
};
}
}
@@ -0,0 +1,250 @@
package com.yxtech.ossutil.util;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.Header;
import org.apache.http.HttpStatus;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.entity.mime.HttpMultipartMode;
import org.apache.http.entity.mime.MultipartEntityBuilder;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.util.EntityUtils;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
/**
* IOSP OSS 文件工具类
* - OAuth2 client_credentials 鉴权,内存缓存 token401 自动重试
* - 共享连接池,复用 TCP 连接
* - 文件下载/预览,文件类型白名单校验
*/
@Slf4j
public class XjOssUtil {
// ---- Setter ----
// ---- 配置(由 XjOssConfig 注入) ----
@Setter
private static String gatewayUrl;
@Setter
private static String clientId;
@Setter
private static String clientSecret;
@Setter
private static String organizationId;
@Setter
private static String configCode;
@Setter
private static String directory;
/** 允许访问的文件扩展名,空集合表示不限制 */
private static Set<String> allowedExts = Collections.emptySet();
// ---- token 内存缓存 ----
private static final AtomicReference<String> cachedToken = new AtomicReference<>();
/** 过期时间戳(ms),提前 60s 刷新 */
private static final AtomicLong tokenExpireAt = new AtomicLong(0);
// ---- 共享连接池 ----
private static final PoolingHttpClientConnectionManager CM;
/** 普通请求客户端(跟随重定向) */
private static final CloseableHttpClient HTTP_CLIENT;
/** 取 Location 专用客户端(禁止重定向) */
private static final CloseableHttpClient NO_REDIRECT_CLIENT;
private static final RequestConfig REQUEST_CONFIG = RequestConfig.custom()
.setConnectTimeout(10_000)
.setSocketTimeout(10 * 60_000)
.setConnectionRequestTimeout(3_000)
.build();
static {
CM = new PoolingHttpClientConnectionManager(60, TimeUnit.SECONDS);
CM.setMaxTotal(200);
CM.setDefaultMaxPerRoute(50);
HTTP_CLIENT = HttpClients.custom()
.setConnectionManager(CM)
.setDefaultRequestConfig(REQUEST_CONFIG)
.build();
NO_REDIRECT_CLIENT = HttpClientBuilder.create()
.setConnectionManager(CM)
.setDefaultRequestConfig(REQUEST_CONFIG)
.disableRedirectHandling()
.build();
}
public static void setFileTypeWhiteList(String v) {
if (v == null || v.isBlank()) {
allowedExts = Collections.emptySet();
} else {
allowedExts = Arrays.stream(v.split(","))
.map(s -> s.trim().toLowerCase())
.filter(s -> !s.isEmpty())
.collect(Collectors.toUnmodifiableSet());
}
}
// ---- 鉴权 ----
public static String getToken() {
if (System.currentTimeMillis() < tokenExpireAt.get()) {
return cachedToken.get();
}
return authenticate();
}
public static synchronized String authenticate() {
if (System.currentTimeMillis() < tokenExpireAt.get()) {
return cachedToken.get();
}
HttpPost post = new HttpPost(gatewayUrl + "/oauth/oauth/token");
post.setConfig(REQUEST_CONFIG);
post.setEntity(MultipartEntityBuilder.create()
.setMode(HttpMultipartMode.BROWSER_COMPATIBLE)
.setCharset(StandardCharsets.UTF_8)
.addTextBody("grant_type", "client_credentials")
.addTextBody("client_id", clientId)
.addTextBody("client_secret", clientSecret)
.build());
try (CloseableHttpResponse resp = HTTP_CLIENT.execute(post)) {
int status = resp.getStatusLine().getStatusCode();
if (status != HttpStatus.SC_OK) {
log.error("[XjOssUtil] 鉴权失败,状态码: {}", status);
return null;
}
JSONObject json = JSON.parseObject(EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8));
String token = json.getString("access_token");
int expiresIn = json.getIntValue("expires_in");
cachedToken.set(token);
tokenExpireAt.set(System.currentTimeMillis() + Math.max(expiresIn - 60, 30) * 1000L);
log.info("[XjOssUtil] 鉴权成功,有效期 {}s", expiresIn);
return token;
} catch (Exception e) {
log.error("[XjOssUtil] 鉴权异常", e);
return null;
}
}
// ---- 文件下载 ----
/**
* 校验文件类型白名单,不合法时抛出 IllegalArgumentException
*
* @param ext 文件扩展名(小写,不含点)
*/
public static void checkExt(String ext) {
if (!allowedExts.isEmpty() && !allowedExts.contains(ext)) {
throw new IllegalArgumentException("不支持的文件类型: " + ext);
}
}
/**
* 下载文件,返回文件输入流,调用方负责关闭
*
* @param fileKey 短文件 key,如 808fec...@aa.png
*/
public static InputStream down(String fileKey) {
// 防御性校验(Controller 已校验,此处作为纵深防御)
if (fileKey == null || fileKey.isBlank() || fileKey.contains("..") || fileKey.contains("\0")) {
log.warn("[XjOssUtil] 拒绝非法 fileKey");
return null;
}
String locationUrl = getLocationUrl(buildFullFileKey(fileKey), false);
if (locationUrl == null) return null;
// SSRF 防护:只允许 http/https 协议
if (!locationUrl.startsWith("http://") && !locationUrl.startsWith("https://")) {
log.error("[XjOssUtil] Location URL 协议非法,拒绝请求: {}", locationUrl);
return null;
}
HttpGet get = new HttpGet(locationUrl);
get.setConfig(REQUEST_CONFIG);
try {
CloseableHttpResponse resp = HTTP_CLIENT.execute(get);
int status = resp.getStatusLine().getStatusCode();
if (status != HttpStatus.SC_OK) {
log.error("[XjOssUtil] 获取文件流失败,状态码: {}", status);
resp.close();
return null;
}
return new ResponseInputStream(resp);
} catch (Exception e) {
log.error("[XjOssUtil] 文件下载异常", e);
return null;
}
}
// ---- 私有方法 ----
private static String buildFullFileKey(String fileKey) {
String dir = directory.endsWith("/") ? directory : directory + "/";
return dir + "0/" + fileKey;
}
private static String getLocationUrl(String fullFileKey, boolean retried) {
try {
URI uri = new URIBuilder(gatewayUrl + "/hfle/v2/" + organizationId + "/files/" + configCode + "/download-by-key")
.addParameter("fileKey", fullFileKey).build();
HttpGet get = new HttpGet(uri);
get.setConfig(REQUEST_CONFIG);
get.addHeader("Authorization", "Bearer " + getToken());
try (CloseableHttpResponse resp = NO_REDIRECT_CLIENT.execute(get)) {
int status = resp.getStatusLine().getStatusCode();
if (status == HttpStatus.SC_UNAUTHORIZED && !retried) {
log.warn("[XjOssUtil] token 过期,重新鉴权后重试");
tokenExpireAt.set(0);
return getLocationUrl(fullFileKey, true);
}
if (status != HttpStatus.SC_MOVED_TEMPORARILY && status != HttpStatus.SC_SEE_OTHER) {
log.error("[XjOssUtil] download-by-key 非预期状态码: {}", status);
return null;
}
Header location = resp.getFirstHeader("Location");
if (location == null) {
log.error("[XjOssUtil] download-by-key 未返回 Location");
return null;
}
return location.getValue();
}
} catch (Exception e) {
log.error("[XjOssUtil] 获取 Location 异常", e);
return null;
}
}
// ---- 内部类:包装响应流,close 时归还连接 ----
private static class ResponseInputStream extends InputStream {
private final InputStream body;
private final CloseableHttpResponse response;
ResponseInputStream(CloseableHttpResponse response) throws IOException {
this.response = response;
this.body = response.getEntity().getContent();
}
@Override public int read() throws IOException { return body.read(); }
@Override public int read(byte[] b, int off, int len) throws IOException { return body.read(b, off, len); }
@Override
public void close() throws IOException {
try { body.close(); } finally { response.close(); }
}
}
}
+26
View File
@@ -0,0 +1,26 @@
server.port: 8080
solon.config.load:
- "app-env.yml" #可以是环境相关的
solon.app:
name: 'ossutil'
group: 'yxtech'
solon.logging:
appender:
console:
level: INFO
file:
level: INFO
# IOSP OSS 配置
xj:
oss:
# gateway-url: http://api.iosp.xjyt.petrochina
gateway-url: http://api.iosp.ydpt.tech
client-id: jkglpt
client-secret: jkglpt@12345!
organization-id: 0
config-code: FILE-OSS-0a85c4dd40ca745a598b872c7974bcaca
directory: scyx/scfz/aygc/jkgl/
# 允许访问的文件类型,逗号分隔;留空则不限制
file-type-white-list: json,dat,svg,dwg,pr,pptm,ai,bak,pptx,lic,db,caj,wps,xmind,eps,exe,doc,docx,xls,xlsx,ppt,pps,pdf,xml,webp,MOV,MP4,HEVC,wmv,rmvb,rm,odt,3gp,swf,gz,tgz,log,bz,bz2,tbz,zip,rar,tar,7z,mp3,mid,ogg,mpga,mp4a,wav,wma,avi,dv,mp4,bmp,xlsm,dps,bak,cdr,et,kdh,nh,,nh,mpeg,mpg,mov,wm,flv,mkv,bmp,jpg,jpeg,gif,png,tif,tiff,tga,psd,txt,php,html,htm,js,css,rtf,rtfd,py,java,rb,sh,pl,sql,xml,gdb,gdbx
+19
View File
@@ -0,0 +1,19 @@
package features;
import com.yxtech.ossutil.App;
import org.junit.jupiter.api.Test;
import org.noear.solon.test.HttpTester;
import org.noear.solon.test.SolonTest;
import java.io.IOException;
@SolonTest(App.class)
public class HelloTest extends HttpTester {
@Test
public void hello() throws IOException {
assert path("/hello?name=world").get().contains("world");
assert path("/hello?name=solon").get().contains("solon");
}
}