feat(wiki): download original raw material file

This commit is contained in:
matevip 2026-04-26 20:46:52 +08:00
parent 0d78beb44f
commit 4a15027a98
5 changed files with 114 additions and 0 deletions

View File

@ -296,6 +296,80 @@ public class WikiController {
return R.ok();
}
@RequireWorkspaceRole("viewer")
@Operation(summary = "下载原始材料")
@GetMapping("/knowledge-bases/{kbId}/raw/{rawId}/download")
public org.springframework.http.ResponseEntity<org.springframework.core.io.Resource> downloadRaw(
@PathVariable Long kbId,
@PathVariable Long rawId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) throws IOException {
verifyKBWorkspace(kbId, workspaceId);
WikiRawMaterialEntity raw = rawService.getById(rawId);
if (raw == null || !kbId.equals(raw.getKbId())) {
return org.springframework.http.ResponseEntity.notFound().build();
}
String rawTitle = raw.getTitle();
String filename = (rawTitle != null && !rawTitle.isBlank())
? rawTitle : ("source-" + rawId);
org.springframework.core.io.Resource resource;
long contentLength;
org.springframework.http.MediaType mediaType;
String sourceType = raw.getSourceType();
if ("text".equals(sourceType)) {
// Text materials live in the DB column re-encode the stored content as bytes.
String content = raw.getOriginalContent();
if (content == null) {
return org.springframework.http.ResponseEntity.notFound().build();
}
byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
resource = new org.springframework.core.io.ByteArrayResource(bytes);
contentLength = bytes.length;
mediaType = org.springframework.http.MediaType.parseMediaType("text/plain;charset=UTF-8");
// Manually-pasted text rows often have no extension on the title give the
// download a sane suffix so the OS knows what to do with it.
if (!filename.contains(".")) filename = filename + ".txt";
} else {
// Binary materials live on disk sandbox to the configured upload dir so
// a tampered source_path can't escape and serve arbitrary files.
String sourcePath = raw.getSourcePath();
if (sourcePath == null || sourcePath.isBlank()) {
return org.springframework.http.ResponseEntity.notFound().build();
}
Path path = Paths.get(sourcePath).toAbsolutePath().normalize();
Path uploadDir = Paths.get(properties.getUploadDir()).toAbsolutePath().normalize();
if (!path.startsWith(uploadDir)) {
log.warn("[Wiki] Download rejected: rawId={} path={} outside uploadDir={}",
rawId, path, uploadDir);
return org.springframework.http.ResponseEntity
.status(org.springframework.http.HttpStatus.FORBIDDEN).build();
}
if (!Files.isRegularFile(path)) {
return org.springframework.http.ResponseEntity.notFound().build();
}
resource = new org.springframework.core.io.FileSystemResource(path);
contentLength = Files.size(path);
mediaType = org.springframework.http.MediaType.APPLICATION_OCTET_STREAM;
}
// RFC 5987 provide both ASCII-safe filename= (for old browsers) and
// UTF-8 filename*= so non-ASCII titles (e.g. 中医诊断学.docx) survive intact.
String asciiFallback = filename.replaceAll("[^\\x20-\\x7E]", "_")
.replace("\"", "_").replace("\\", "_");
String encoded = java.net.URLEncoder.encode(filename, StandardCharsets.UTF_8)
.replace("+", "%20");
String contentDisposition = "attachment; filename=\"" + asciiFallback
+ "\"; filename*=UTF-8''" + encoded;
return org.springframework.http.ResponseEntity.ok()
.contentType(mediaType)
.contentLength(contentLength)
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
.body(resource);
}
// ==================== Wiki Pages ====================
@RequireWorkspaceRole("viewer")

View File

@ -441,6 +441,10 @@ export const wikiApi = {
http.delete(`/wiki/knowledge-bases/${kbId}/raw/${rawId}`),
reprocessRaw: (kbId: number, rawId: number) =>
http.post(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/reprocess`),
downloadRaw: (kbId: number, rawId: number) =>
http.get<Blob>(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/download`, {
responseType: 'blob',
}),
// Wiki Pages
listPages: (kbId: number, rawId?: number) =>

View File

@ -1239,6 +1239,8 @@ export default {
noRawMaterials: 'No raw materials yet',
reprocess: 'Reprocess',
resume: 'Resume',
download: 'Download original file',
downloadFailed: 'Download failed',
processAll: 'Process All Pending',
materialTitle: 'Title',
materialContent: 'Content',

View File

@ -1249,6 +1249,8 @@ export default {
noRawMaterials: '暂无原始材料',
reprocess: '重新处理',
resume: '继续生成',
download: '下载原始文件',
downloadFailed: '下载失败',
processAll: '处理所有待处理材料',
materialTitle: '标题',
materialContent: '内容',

View File

@ -172,6 +172,13 @@
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
</button>
<button
v-if="raw.processingStatus !== 'uploading'"
class="btn-icon" :title="t('wiki.download')"
@click="downloadRaw(raw)"
>
<el-icon :size="14"><Download /></el-icon>
</button>
<button class="btn-icon btn-icon-danger" :title="t('common.delete')" @click="deleteRaw(raw.id)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
@ -251,6 +258,7 @@
import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import { useWikiStore } from '@/stores/useWikiStore'
import { wikiApi } from '@/api/index'
import JobStageBar from './JobStageBar.vue'
@ -548,6 +556,30 @@ async function deleteRaw(rawId: number) {
await store.fetchRawMaterials(store.currentKB.id)
}
async function downloadRaw(raw: { id: number; title?: string }) {
if (!store.currentKB) return
try {
// The http interceptor returns the raw body for non-R-shaped responses,
// so this resolves directly to the Blob (no .data unwrap needed).
const blob = (await wikiApi.downloadRaw(store.currentKB.id, raw.id)) as unknown as Blob
let filename = raw.title && raw.title.trim().length > 0 ? raw.title : `raw-${raw.id}`
if (!filename.includes('.')) filename += '.txt'
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
// Revoke on next tick some browsers cancel the in-flight download if we
// revoke synchronously before the click handler returns.
setTimeout(() => URL.revokeObjectURL(url), 0)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
ElMessage.error(`${t('wiki.downloadFailed')}: ${msg}`)
}
}
async function processAll() {
if (!store.currentKB) return
const kbId = store.currentKB.id