feat 文件管理中心支持断点下载文件

This commit is contained in:
bwcx_jzy 2023-03-17 23:22:31 +08:00
parent 18110f2daf
commit e5762fc64a
No known key found for this signature in database
GPG Key ID: 5E48E9372088B9E5
8 changed files with 330 additions and 17 deletions

View File

@ -56,6 +56,11 @@ public class ServerOpenApi {
* 触发构建 批量触发
*/
public static final String BUILD_TRIGGER_BUILD_BATCH = API + "build_batch";
/**
* 文件下载
*/
public static final String FILE_STORAGE_DOWNLOAD = API + "file-storage/download/{id}/{token}";
/**
* 获取当前构建状态
*/

View File

@ -26,9 +26,7 @@ import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.DateTime;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import io.jpom.common.BaseServerController;
import io.jpom.common.JsonMessage;
import io.jpom.common.ServerConst;
import io.jpom.common.*;
import io.jpom.common.validator.ValidatorItem;
import io.jpom.controller.outgiving.OutGivingWhitelistService;
import io.jpom.func.files.model.FileStorageModel;
@ -38,6 +36,7 @@ import io.jpom.model.user.UserModel;
import io.jpom.permission.ClassFeature;
import io.jpom.permission.Feature;
import io.jpom.permission.MethodFeature;
import io.jpom.service.user.TriggerTokenLogServer;
import io.jpom.system.ServerConfig;
import org.springframework.http.MediaType;
import org.springframework.util.Assert;
@ -51,6 +50,8 @@ import top.jpom.model.PageResultDto;
import javax.servlet.http.HttpServletRequest;
import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
/**
* @author bwcx_jzy
@ -63,13 +64,16 @@ public class FileStorageController extends BaseServerController {
private final ServerConfig serverConfig;
private final FileStorageService fileStorageService;
private final OutGivingWhitelistService outGivingWhitelistService;
private final TriggerTokenLogServer triggerTokenLogServer;
public FileStorageController(ServerConfig serverConfig,
FileStorageService fileStorageService,
OutGivingWhitelistService outGivingWhitelistService) {
OutGivingWhitelistService outGivingWhitelistService,
TriggerTokenLogServer triggerTokenLogServer) {
this.serverConfig = serverConfig;
this.fileStorageService = fileStorageService;
this.outGivingWhitelistService = outGivingWhitelistService;
this.triggerTokenLogServer = triggerTokenLogServer;
}
/**
@ -234,14 +238,25 @@ public class FileStorageController extends BaseServerController {
return JsonMessage.success("删除成功");
}
@PostMapping(value = "download", produces = MediaType.APPLICATION_JSON_VALUE)
/**
* 远程下载
*
* @param url 远程 url
* @param keepDay 保留天数
* @param description 描述
* @param global 是否全局共享
* @param request 请求
* @return json
* @throws IOException io
*/
@PostMapping(value = "remote-download", produces = MediaType.APPLICATION_JSON_VALUE)
@Feature(method = MethodFeature.REMOTE_DOWNLOAD)
public JsonMessage<String> download(
@ValidatorItem String url,
Integer keepDay,
String description,
Boolean global,
HttpServletRequest request) throws IOException {
@ValidatorItem String url,
Integer keepDay,
String description,
Boolean global,
HttpServletRequest request) throws IOException {
// 验证远程 地址
ServerWhitelist whitelist = outGivingWhitelistService.getServerWhitelistData(request);
whitelist.checkAllowRemoteDownloadHost(url);
@ -249,4 +264,42 @@ public class FileStorageController extends BaseServerController {
fileStorageService.download(url, global, workspace, keepDay, description);
return JsonMessage.success("开始异步下载");
}
/**
* get a trigger url
*
* @param id id
* @return json
*/
@GetMapping(value = "trigger-url", produces = MediaType.APPLICATION_JSON_VALUE)
@Feature(method = MethodFeature.EDIT)
public JsonMessage<Map<String, String>> getTriggerUrl(@ValidatorItem String id, String rest, HttpServletRequest request) {
FileStorageModel item = fileStorageService.getByKey(id, request);
UserModel user = getUser();
FileStorageModel updateInfo;
if (StrUtil.isEmpty(item.getTriggerToken()) || StrUtil.isNotEmpty(rest)) {
updateInfo = new FileStorageModel();
updateInfo.setId(id);
updateInfo.setTriggerToken(triggerTokenLogServer.restToken(item.getTriggerToken(), fileStorageService.typeName(),
item.getId(), user.getId()));
fileStorageService.updateById(updateInfo);
} else {
updateInfo = item;
}
Map<String, String> map = this.getBuildToken(updateInfo);
return JsonMessage.success(StrUtil.isEmpty(rest) ? "ok" : "重置成功", map);
}
private Map<String, String> getBuildToken(FileStorageModel item) {
String contextPath = UrlRedirectUtil.getHeaderProxyPath(getRequest(), ServerConst.PROXY_PATH);
String url = ServerOpenApi.FILE_STORAGE_DOWNLOAD.
replace("{id}", item.getId()).
replace("{token}", item.getTriggerToken());
String triggerBuildUrl = String.format("/%s/%s", contextPath, url);
Map<String, String> map = new HashMap<>(10);
map.put("triggerDownloadUrl", FileUtil.normalize(triggerBuildUrl));
map.put("id", item.getId());
map.put("token", item.getTriggerToken());
return map;
}
}

View File

@ -93,6 +93,10 @@ public class FileStorageModel extends BaseWorkspaceModel {
*/
@PropIgnore
private Boolean exists;
/**
* 触发器 token
*/
private String triggerToken;
/**
* 设置保留天数的过期时间

View File

@ -42,6 +42,7 @@ import io.jpom.common.ISystemTask;
import io.jpom.common.ServerConst;
import io.jpom.func.files.model.FileStorageModel;
import io.jpom.service.IStatusRecover;
import io.jpom.service.ITriggerToken;
import io.jpom.service.h2db.BaseWorkspaceService;
import io.jpom.system.ServerConfig;
import io.jpom.system.extconf.BuildExtConfig;
@ -63,7 +64,7 @@ import java.util.concurrent.ConcurrentHashMap;
*/
@Service
@Slf4j
public class FileStorageService extends BaseWorkspaceService<FileStorageModel> implements ISystemTask, IStatusRecover {
public class FileStorageService extends BaseWorkspaceService<FileStorageModel> implements ISystemTask, IStatusRecover, ITriggerToken {
private final ServerConfig serverConfig;
private final JpomApplication configBean;
@ -294,4 +295,15 @@ public class FileStorageService extends BaseWorkspaceService<FileStorageModel> i
.set("status", 0);
return this.update(updateEntity, where);
}
@Override
public String typeName() {
return getTableName();
}
@Override
public List<Entity> allEntityTokens() {
String sql = "select id,triggerToken from " + getTableName() + " where triggerToken is not null";
return super.query(sql);
}
}

View File

@ -0,0 +1,160 @@
/*
* The MIT License (MIT)
*
* Copyright (c) 2019 Code Technology Studio
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package io.jpom.func.openapi.controller;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.*;
import cn.hutool.extra.servlet.ServletUtil;
import io.jpom.common.BaseJpomController;
import io.jpom.common.ServerOpenApi;
import io.jpom.common.interceptor.NotLogin;
import io.jpom.func.files.model.FileStorageModel;
import io.jpom.func.files.service.FileStorageService;
import io.jpom.model.user.UserModel;
import io.jpom.service.user.TriggerTokenLogServer;
import io.jpom.system.ServerConfig;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.util.List;
/**
* @author bwcx_jzy
* @since 2023/3/17
*/
@RestController
@NotLogin
@Slf4j
public class FileStorageApiController extends BaseJpomController {
private final TriggerTokenLogServer triggerTokenLogServer;
private final FileStorageService fileStorageService;
private final ServerConfig serverConfig;
public FileStorageApiController(TriggerTokenLogServer triggerTokenLogServer,
FileStorageService fileStorageService,
ServerConfig serverConfig) {
this.triggerTokenLogServer = triggerTokenLogServer;
this.fileStorageService = fileStorageService;
this.serverConfig = serverConfig;
}
@GetMapping(value = ServerOpenApi.FILE_STORAGE_DOWNLOAD, produces = MediaType.APPLICATION_JSON_VALUE)
public void download(@PathVariable String id, @PathVariable String token,
HttpServletRequest request,
HttpServletResponse response) {
FileStorageModel storageModel = fileStorageService.getByKey(id);
Assert.notNull(storageModel, "没有对应数据");
Assert.state(StrUtil.equals(token, storageModel.getTriggerToken()), "token错误,或者已经失效");
//
UserModel userModel = triggerTokenLogServer.getUserByToken(token, fileStorageService.typeName());
//
Assert.notNull(userModel, "token错误,或者已经失效:-1");
//
File storageSavePath = serverConfig.fileStorageSavePath();
File fileStorageFile = FileUtil.file(storageSavePath, storageModel.getPath());
Assert.state(FileUtil.isFile(fileStorageFile), "文件已经不存在啦");
long fileSize = FileUtil.size(fileStorageFile);
//
String name = ReUtil.replaceAll(storageModel.getName(), "[\\s\\\\/:\\*\\?\\\"<>\\|]", "");
if (StrUtil.isEmpty(name)) {
name = fileStorageFile.getName();
} else {
name += "." + storageModel.getExtName();
}
String contentType = ObjectUtil.defaultIfNull(FileUtil.getMimeType(name), "application/octet-stream");
final String charset = ObjectUtil.defaultIfNull(response.getCharacterEncoding(), CharsetUtil.UTF_8);
response.setHeader("Content-Disposition", StrUtil.format("attachment;filename=\"{}\"",
URLUtil.encode(name, CharsetUtil.charset(charset))));
response.setContentType(contentType);
//
// 解析断点续传相关信息
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
String range = ServletUtil.getHeader(request, HttpHeaders.RANGE, CharsetUtil.CHARSET_UTF_8);
log.debug("下载文件 {} {} {}", storageModel.getId(), name, range);
long fromPos = 0, toPos = 0, downloadSize;
if (StrUtil.isEmpty(range)) {
downloadSize = fileSize;
} else {
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
List<String> list = StrUtil.splitTrim(range, "=");
String rangeByte = CollUtil.getLast(list);
long[] split = StrUtil.splitToLong(rangeByte, "-");
Assert.state(split != null, "range 传入的信息不正确");
fromPos = split[0];
if (split.length == 2) {
toPos = split[1];
}
downloadSize = toPos > fromPos ? (int) (toPos - fromPos) : (int) (fileSize - fromPos);
}
response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(downloadSize));
// Copy the stream to the response's output stream.
RandomAccessFile in = null;
OutputStream out = null;
try {
out = response.getOutputStream();
in = new RandomAccessFile(fileStorageFile, "r");
// 设置下载起始位置
if (fromPos > 0) {
in.seek(fromPos);
}
// 缓冲区大小
int bufLen = (int) Math.min(downloadSize, IoUtil.DEFAULT_BUFFER_SIZE);
byte[] buffer = new byte[bufLen];
int num;
int count = 0;
// 当前写到客户端的大小
while ((num = in.read(buffer)) != -1) {
out.write(buffer, 0, num);
count += num;
//处理最后一段计算不满缓冲区的大小
if (downloadSize - count < bufLen) {
bufLen = (int) (downloadSize - count);
if (bufLen == 0) {
break;
}
buffer = new byte[bufLen];
}
}
response.flushBuffer();
} catch (Exception e) {
log.error("数据下载失败", e);
} finally {
IoUtil.close(out);
IoUtil.close(in);
}
}
}

View File

@ -32,5 +32,6 @@ ADD,MACHINE_SSH_INFO,osMaxOccupyDisk,Double,,,占用磁盘,false
ADD,MACHINE_SSH_INFO,osMaxOccupyDiskName,String,255,,占用磁盘 分区名,false
ADD,MACHINE_SSH_INFO,javaVersion,String,255,,java版本,false
ADD,MACHINE_SSH_INFO,jpomAgentPid,Integer,,,jpom agent进程号
ADD,FILE_STORAGE,status,TINYINT,,,true,false,0 下载中 1 下载完成 3 下载异常,
ADD,FILE_STORAGE,progressDesc,String,255,,true,false,进度描述,
ADD,FILE_STORAGE,status,TINYINT,,,0 下载中 1 下载完成 3 下载异常,false,
ADD,FILE_STORAGE,progressDesc,String,255,,进度描述,false,
ADD,FILE_STORAGE,triggerToken,String,200,,触发器token,false,

1 alterType,tableName,name,type,len,defaultValue,comment,notNull
32 ADD,MACHINE_SSH_INFO,osMaxOccupyDiskName,String,255,,占用磁盘 分区名,false
33 ADD,MACHINE_SSH_INFO,javaVersion,String,255,,java版本,false
34 ADD,MACHINE_SSH_INFO,jpomAgentPid,Integer,,,jpom agent进程号
35 ADD,FILE_STORAGE,status,TINYINT,,,true,false,0 下载中 1 下载完成 3 下载异常, ADD,FILE_STORAGE,status,TINYINT,,,0 下载中 1 下载完成 3 下载异常,false,
36 ADD,FILE_STORAGE,progressDesc,String,255,,true,false,进度描述, ADD,FILE_STORAGE,progressDesc,String,255,,进度描述,false,
37 ADD,FILE_STORAGE,triggerToken,String,200,,触发器token,false,

View File

@ -45,7 +45,7 @@ export function fileEdit(params) {
// 下载远程文件
export function remoteDownload(params) {
return axios({
url: "/file-storage/download",
url: "/file-storage/remote-download",
method: "post",
data: params,
});
@ -69,6 +69,15 @@ export function delFile(params) {
});
}
// 触发器
export function triggerUrl(params) {
return axios({
url: "/file-storage/trigger-url",
method: "get",
params: params,
});
}
export const sourceMap = {
0: "上传",
1: "构建",

View File

@ -65,6 +65,7 @@
<template slot="operation" slot-scope="text, record">
<a-space>
<a-button type="primary" size="small" @click="handleEdit(record)">编辑</a-button>
<a-button size="small" :disabled="!record.exists" type="primary" @click="handleTrigger(record)">触发器</a-button>
<a-button type="danger" size="small" @click="handleDelete(record)">删除</a-button>
</a-space>
</template>
@ -155,15 +156,51 @@
</a-form-model-item>
</a-form-model>
</a-modal>
<!-- 断点下载 -->
<a-modal destroyOnClose v-model="triggerVisible" title="断点下载" width="50%" :footer="null" :maskClosable="false">
<a-form-model ref="editTriggerForm" :model="temp" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-tabs default-active-key="1">
<template slot="tabBarExtraContent">
<a-tooltip title="重置下载 token 信息,重置后之前的下载 token 将失效">
<a-button type="primary" size="small" @click="resetTrigger">重置</a-button>
</a-tooltip>
</template>
<a-tab-pane key="1" tab="断点下载">
<a-space style="display: block" direction="vertical" align="baseline">
<a-alert
v-clipboard:copy="`${temp.triggerDownloadUrl}`"
v-clipboard:success="
() => {
tempVue.prototype.$notification.success({ message: '复制成功' });
}
"
v-clipboard:error="
() => {
tempVue.prototype.$notification.error({ message: '复制失败' });
}
"
type="info"
:message="`下载地址(点击可以复制)`"
>
<template slot="description">
<a-tag>GET</a-tag> <span>{{ `${temp.triggerDownloadUrl}` }} </span>
<a-icon type="copy" />
</template>
</a-alert>
</a-space>
</a-tab-pane>
</a-tabs>
</a-form-model>
</a-modal>
</div>
</div>
</template>
<script>
import { CHANGE_PAGE, COMPUTED_PAGINATION, PAGE_DEFAULT_LIST_QUERY, parseTime, renderSize, formatDuration } from "@/utils/const";
import { fileStorageList, uploadFile, uploadFileMerge, fileEdit, hasFile, delFile, sourceMap, remoteDownload, statusMap } from "@/api/tools/file-storage";
import { fileStorageList, uploadFile, uploadFileMerge, fileEdit, hasFile, delFile, sourceMap, remoteDownload, statusMap, triggerUrl } from "@/api/tools/file-storage";
import { uploadPieces } from "@/utils/upload-pieces";
import Vue from "vue";
export default {
data() {
return {
@ -206,7 +243,7 @@ export default {
scopedSlots: { customRender: "time" },
width: "100px",
},
{ title: "操作", dataIndex: "operation", ellipsis: true, scopedSlots: { customRender: "operation" }, fixed: "right", width: "120px" },
{ title: "操作", dataIndex: "operation", ellipsis: true, scopedSlots: { customRender: "operation" }, fixed: "right", width: "200px" },
],
rules: {
name: [{ required: true, message: "请输入文件名称", trigger: "blur" }],
@ -222,6 +259,8 @@ export default {
uploadVisible: false,
editVisible: false,
uploadRemoteFileVisible: false,
tempVue: null,
triggerVisible: false,
};
},
computed: {
@ -422,6 +461,36 @@ export default {
});
});
},
//
handleTrigger(record) {
this.temp = Object.assign({}, record);
this.tempVue = Vue;
triggerUrl({
id: record.id,
}).then((res) => {
if (res.code === 200) {
this.fillTriggerResult(res);
this.triggerVisible = true;
}
});
},
//
resetTrigger() {
triggerUrl({
id: this.temp.id,
rest: "rest",
}).then((res) => {
if (res.code === 200) {
this.$notification.success({
message: res.msg,
});
this.fillTriggerResult(res);
}
});
},
fillTriggerResult(res) {
this.temp = { ...this.temp, triggerDownloadUrl: `${location.protocol}//${location.host}${res.data.triggerDownloadUrl}` };
},
},
};
</script>