feat(m8): P1 MinIO 对象存储与文件管理

- 迁移 000023 gateway.file_objects(personal/system 归属隔离 + 部分索引)
- internal/platform/storage:minio-go 适配(端点 scheme 剥离、流式 PutObject/Open/Delete)
- internal/workbench/files.go:FileService(sha256 校验、PutObject-then-insert 回滚、delete 先删行再删对象)
- admin /api/v1/admin/files + portal /api/v1/portal/files 处理器(流式上传下载、Content-Disposition)
- RBAC file:read/file:manage;菜单加文件管理 + 门户文件仓库
- compose 增 minio 服务(S3_* anchor、不暴露端口);nginx client_max_body_size 32m→256m
- 管理端文件管理页 + 门户个人文件仓;集成测试 TestFileObjectLifecycle 连真 MinIO 通过
- healthz object_storage:true;README/PRODUCTION/进展文档同步

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ben
2026-08-12 14:08:38 +08:00
parent 5759c1862e
commit 6708c226a5
26 changed files with 1180 additions and 40 deletions
+41
View File
@@ -0,0 +1,41 @@
import axios from 'axios'
import request from '@/utils/http'
import { useUserStore } from '@/store/modules/user'
export interface FileObject {
id: string
original_name: string
content_type: string
size_bytes: number
content_sha256: string
scope: string
owner_user_id?: string
created_by: string
created_at: string
updated_at: string
}
export const fetchFiles = () => request.get<FileObject[]>({ url: '/api/v1/admin/files' })
export const uploadFile = (file: File) => {
const form = new FormData()
form.append('file', file)
return request.post<FileObject>({ url: '/api/v1/admin/files', data: form, timeout: 120000 })
}
export const deleteFile = (id: string) => request.del({ url: `/api/v1/admin/files/${id}` })
// 文件体以 blob 流式下载,绕开统一响应拦截器(其期望 JSON envelope)。
export async function downloadFile(id: string, filename: string) {
const { accessToken } = useUserStore()
const token = accessToken.startsWith('Bearer ') ? accessToken : `Bearer ${accessToken}`
const res = await axios.get(`/api/v1/admin/files/${id}/download`, {
baseURL: import.meta.env.VITE_API_URL,
headers: { Authorization: token },
responseType: 'blob'
})
const url = URL.createObjectURL(res.data)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
}
@@ -0,0 +1,55 @@
<template>
<div class="page-content">
<div class="mb-5 flex justify-between">
<div><h2 class="text-xl font-semibold">文件管理</h2><p class="text-g-500 mt-1 text-sm">平台级文件存放于自托管对象存储MinIO上传/下载全部经网关代理</p></div>
<ElUpload :show-file-list="false" :http-request="upload">
<ElButton type="primary" :loading="uploading">上传文件</ElButton>
</ElUpload>
</div>
<ElTable v-loading="loading" :data="files">
<ElTableColumn prop="original_name" label="文件名" min-width="200" show-overflow-tooltip/>
<ElTableColumn label="大小" width="120"><template #default="{row}">{{ formatBytes(row.size_bytes) }}</template></ElTableColumn>
<ElTableColumn prop="content_type" label="类型" width="180" show-overflow-tooltip/>
<ElTableColumn label="SHA-256" width="110"><template #default="{row}">{{ row.content_sha256.slice(0, 10) }}</template></ElTableColumn>
<ElTableColumn prop="created_at" label="上传时间" width="190"/>
<ElTableColumn label="操作" width="160" fixed="right"><template #default="{row}">
<ElButton link type="primary" @click="download(row)">下载</ElButton>
<ElButton link type="danger" @click="remove(row)">删除</ElButton>
</template></ElTableColumn>
</ElTable>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { FileObject, deleteFile, downloadFile, fetchFiles, uploadFile } from '@/api/files'
const loading = ref(false), uploading = ref(false)
const files = ref<FileObject[]>([])
function formatBytes(n: number) {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MiB`
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GiB`
}
async function load() {
loading.value = true
try { files.value = await fetchFiles() } finally { loading.value = false }
}
async function upload({ file }: { file: File }) {
uploading.value = true
try {
await uploadFile(file)
ElMessage.success('上传成功')
await load()
} finally { uploading.value = false }
}
function download(row: FileObject) {
downloadFile(row.id, row.original_name)
}
async function remove(row: FileObject) {
await ElMessageBox.confirm(`确认删除文件「${row.original_name}」?`, '删除确认', { type: 'warning' })
await deleteFile(row.id)
ElMessage.success('已删除')
await load()
}
onMounted(load)
</script>
+27
View File
@@ -1,4 +1,6 @@
import axios from 'axios'
import request from '@/utils/http'
import { useUserStore } from '@/store/modules/user'
export interface Application { id:string;code:string;name:string;description:string;published_version?:number }
export interface Knowledge { id:string;name:string;description:string;document_count:number;chunk_count:number }
@@ -21,6 +23,31 @@ export const fetchStats=(days:number)=>request.get<Stats>({url:'/api/v1/portal/s
export const fetchLogs=(days:number,limit=50)=>request.get<{items:AuditEvent[]}>({url:'/api/v1/portal/logs',params:{days,limit}})
export const changePassword=(params:{old_password:string;new_password:string})=>request.post({url:'/api/v1/portal/password',params})
// --- 个人文件仓库(M8 对象存储)---
export interface FileObject { id:string;original_name:string;content_type:string;size_bytes:number;content_sha256:string;scope:string;created_at:string;updated_at:string }
export const fetchMyFiles=()=>request.get<FileObject[]>({url:'/api/v1/portal/files'})
export const uploadMyFile=(file:File)=>{
const form=new FormData()
form.append('file',file)
return request.post<FileObject>({url:'/api/v1/portal/files',data:form,timeout:120000})
}
export const deleteMyFile=(id:string)=>request.del({url:`/api/v1/portal/files/${id}`})
export async function downloadMyFile(id:string,filename:string){
const { accessToken } = useUserStore()
const token = accessToken.startsWith('Bearer ')?accessToken:`Bearer ${accessToken}`
const res = await axios.get(`/api/v1/portal/files/${id}/download`,{
baseURL:import.meta.env.VITE_API_URL,
headers:{Authorization:token},
responseType:'blob'
})
const url = URL.createObjectURL(res.data)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
}
// --- 资源市场 ---
export interface MarketItem { type:string;code:string;name:string;description:string;category_id?:string;category_name:string;tags:string[];department_ids:string[];updated_at:string }
@@ -0,0 +1,54 @@
<template>
<div class="page-content">
<div class="mb-5 flex justify-between">
<div><h2 class="text-xl font-semibold">我的文件仓库</h2><p class="text-g-500 mt-1 text-sm">个人文件存储于平台对象存储仅自己可见可通过 API 下载引用</p></div>
<ElUpload :show-file-list="false" :http-request="upload">
<ElButton type="primary" :loading="uploading">上传文件</ElButton>
</ElUpload>
</div>
<ElTable v-loading="loading" :data="files">
<ElTableColumn prop="original_name" label="文件名" min-width="200" show-overflow-tooltip/>
<ElTableColumn label="大小" width="120"><template #default="{row}">{{ formatBytes(row.size_bytes) }}</template></ElTableColumn>
<ElTableColumn prop="content_type" label="类型" width="180" show-overflow-tooltip/>
<ElTableColumn prop="created_at" label="上传时间" width="190"/>
<ElTableColumn label="操作" width="160" fixed="right"><template #default="{row}">
<ElButton link type="primary" @click="download(row)">下载</ElButton>
<ElButton link type="danger" @click="remove(row)">删除</ElButton>
</template></ElTableColumn>
</ElTable>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { FileObject, deleteMyFile, downloadMyFile, fetchMyFiles, uploadMyFile } from '@/api/portal'
const loading = ref(false), uploading = ref(false)
const files = ref<FileObject[]>([])
function formatBytes(n: number) {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MiB`
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GiB`
}
async function load() {
loading.value = true
try { files.value = await fetchMyFiles() } finally { loading.value = false }
}
async function upload({ file }: { file: File }) {
uploading.value = true
try {
await uploadMyFile(file)
ElMessage.success('上传成功')
await load()
} finally { uploading.value = false }
}
function download(row: FileObject) {
downloadMyFile(row.id, row.original_name)
}
async function remove(row: FileObject) {
await ElMessageBox.confirm(`确认删除文件「${row.original_name}」?`, '删除确认', { type: 'warning' })
await deleteMyFile(row.id)
ElMessage.success('已删除')
await load()
}
onMounted(load)
</script>