feat: 分片上传断点续传、临时目录后台配置与清扫、宇恒云账号管理
- 管理端大文件分片上传与 sessionStorage 续传;Nginx 大请求体/超时 - .chunk-uploads 定期清扫;system_config 后台配置保留时长与扫描间隔 - 宇恒云 POST /register 对接与 yuheng_cloud_register_records 留痕;yuheng_cloud:manage 权限 Made-with: Cursor
This commit is contained in:
@@ -88,4 +88,14 @@ if (!db.getCollectionNames().includes("site_users")) {
|
|||||||
}
|
}
|
||||||
db.site_users.createIndex({ username: 1 }, { unique: true, name: "idx_username", background: true });
|
db.site_users.createIndex({ username: 1 }, { unique: true, name: "idx_username", background: true });
|
||||||
|
|
||||||
|
// 12. yuheng_cloud_register_records(宇恒云 POST /register 本地留痕:username、password)
|
||||||
|
if (!db.getCollectionNames().includes("yuheng_cloud_register_records")) {
|
||||||
|
db.createCollection("yuheng_cloud_register_records");
|
||||||
|
print("已创建集合: yuheng_cloud_register_records");
|
||||||
|
}
|
||||||
|
db.yuheng_cloud_register_records.createIndex(
|
||||||
|
{ created_at: -1 },
|
||||||
|
{ name: "idx_created_at", background: true }
|
||||||
|
);
|
||||||
|
|
||||||
print("集合与索引处理完成。");
|
print("集合与索引处理完成。");
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ export const createUser = (data) => request.post('/admin/users', data)
|
|||||||
export const updateUser = (id, data) => request.put(`/admin/users/${id}`, data)
|
export const updateUser = (id, data) => request.put(`/admin/users/${id}`, data)
|
||||||
export const deleteUser = (id) => request.delete(`/admin/users/${id}`)
|
export const deleteUser = (id) => request.delete(`/admin/users/${id}`)
|
||||||
|
|
||||||
|
// 宇恒云账号(POST 云端 /register + 本地 Mongo 仅记 username/password)
|
||||||
|
export const createYuhengCloudAccount = (data) =>
|
||||||
|
request.post('/admin/yuheng-cloud-accounts', data, { timeout: 60000 })
|
||||||
|
export const listYuhengCloudAccounts = (params) => request.get('/admin/yuheng-cloud-accounts', { params })
|
||||||
|
|
||||||
// 工作空间
|
// 工作空间
|
||||||
export const getWorkspaces = (params) => request.get('/admin/workspaces', { params })
|
export const getWorkspaces = (params) => request.get('/admin/workspaces', { params })
|
||||||
|
|
||||||
@@ -46,6 +51,10 @@ export const updatePaymentConfig = (data) => request.put('/admin/payment-config'
|
|||||||
export const getOfficialSite = () => request.get('/admin/system/official-site')
|
export const getOfficialSite = () => request.get('/admin/system/official-site')
|
||||||
export const setOfficialSite = (siteId) => request.put('/admin/system/official-site', { site_id: siteId })
|
export const setOfficialSite = (siteId) => request.put('/admin/system/official-site', { site_id: siteId })
|
||||||
|
|
||||||
|
/** 分片上传临时目录清理:保留时长、扫描间隔(需 site:manage) */
|
||||||
|
export const getChunkUploadCleanup = () => request.get('/admin/system/chunk-upload-cleanup')
|
||||||
|
export const updateChunkUploadCleanup = (data) => request.put('/admin/system/chunk-upload-cleanup', data)
|
||||||
|
|
||||||
// 站点管理
|
// 站点管理
|
||||||
export const getSites = () => request.get('/admin/sites')
|
export const getSites = () => request.get('/admin/sites')
|
||||||
export const getSiteById = (id) => request.get(`/admin/sites/${id}`)
|
export const getSiteById = (id) => request.get(`/admin/sites/${id}`)
|
||||||
@@ -79,8 +88,33 @@ export const uploadSiteAsset = (siteId, file, opts = {}) => {
|
|||||||
if (opts.folder != null) form.append('folder', opts.folder)
|
if (opts.folder != null) form.append('folder', opts.folder)
|
||||||
form.append('downloadable', opts.downloadable ? 'true' : 'false')
|
form.append('downloadable', opts.downloadable ? 'true' : 'false')
|
||||||
if (opts.preserveFilename) form.append('preserve_filename', 'true')
|
if (opts.preserveFilename) form.append('preserve_filename', 'true')
|
||||||
return request.post(`/admin/sites/${siteId}/assets`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
// 大文件上传:timeout 0 = Axios 不设置请求超时(仍可能受浏览器/系统/代理断开影响)
|
||||||
|
// 超大文件请用分片 API(见 uploadSiteAssetWithResume)
|
||||||
|
return request.post(`/admin/sites/${siteId}/assets`, form, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 0
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 分片上传:创建会话(断点续传) */
|
||||||
|
export const initMultipartUpload = (siteId, body) =>
|
||||||
|
request.post(`/admin/sites/${siteId}/assets/init-multipart`, body, { timeout: 60000 })
|
||||||
|
|
||||||
|
export const getMultipartUploadStatus = (siteId, uploadId) =>
|
||||||
|
request.get(`/admin/sites/${siteId}/assets/multipart/${uploadId}/status`, { timeout: 60000 })
|
||||||
|
|
||||||
|
export const putMultipartChunk = (siteId, uploadId, chunkIndex, blob) =>
|
||||||
|
request.put(`/admin/sites/${siteId}/assets/multipart/${uploadId}/chunk/${chunkIndex}`, blob, {
|
||||||
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
|
timeout: 180000
|
||||||
|
})
|
||||||
|
|
||||||
|
export const completeMultipartUpload = (siteId, uploadId) =>
|
||||||
|
request.post(`/admin/sites/${siteId}/assets/multipart/${uploadId}/complete`, {}, { timeout: 600000 })
|
||||||
|
|
||||||
|
export const abortMultipartUpload = (siteId, uploadId) =>
|
||||||
|
request.delete(`/admin/sites/${siteId}/assets/multipart/${uploadId}`, { timeout: 60000 })
|
||||||
|
|
||||||
export const createSiteFolder = (siteId, path) => request.post(`/admin/sites/${siteId}/folders`, { path })
|
export const createSiteFolder = (siteId, path) => request.post(`/admin/sites/${siteId}/folders`, { path })
|
||||||
export const deleteSiteAsset = (siteId, id) => request.delete(`/admin/sites/${siteId}/assets/${id}`)
|
export const deleteSiteAsset = (siteId, id) => request.delete(`/admin/sites/${siteId}/assets/${id}`)
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { House, User, Folder, ChatDotRound, Setting, Wallet, Monitor, Document, EditPen, Upload, Key, VideoCamera } from '@element-plus/icons-vue'
|
import { House, User, Folder, ChatDotRound, Setting, Wallet, Monitor, Document, EditPen, Upload, Key, VideoCamera, Timer, Link } from '@element-plus/icons-vue'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { getMyPermissions } from '../api/admin'
|
import { getMyPermissions } from '../api/admin'
|
||||||
|
|
||||||
@@ -69,6 +69,8 @@ const menuItems = computed(() => {
|
|||||||
{ index: '/sms-config', title: '短信配置', icon: Setting, permission: 'sms_config' },
|
{ index: '/sms-config', title: '短信配置', icon: Setting, permission: 'sms_config' },
|
||||||
{ index: '/payment-config', title: '支付配置', icon: Wallet, permission: 'payment_config' },
|
{ index: '/payment-config', title: '支付配置', icon: Wallet, permission: 'payment_config' },
|
||||||
{ index: '/sites', title: '站点管理', icon: Monitor, permission: 'site:manage' },
|
{ index: '/sites', title: '站点管理', icon: Monitor, permission: 'site:manage' },
|
||||||
|
{ index: '/chunk-upload-cleanup', title: '分片上传清理', icon: Timer, permission: 'site:manage' },
|
||||||
|
{ index: '/yuheng-cloud-accounts', title: '宇恒云账号', icon: Link, permission: 'yuheng_cloud:manage' },
|
||||||
{ index: '/pages', title: '网页管理', icon: Document, permission: 'page:manage' },
|
{ index: '/pages', title: '网页管理', icon: Document, permission: 'page:manage' },
|
||||||
{ index: '/homepage-edit', title: '首页编辑', icon: EditPen, permission: 'homepage:edit' },
|
{ index: '/homepage-edit', title: '首页编辑', icon: EditPen, permission: 'homepage:edit' },
|
||||||
{ index: '/live-broadcast', title: '视频直播开播', icon: VideoCamera, permission: 'homepage:edit' },
|
{ index: '/live-broadcast', title: '视频直播开播', icon: VideoCamera, permission: 'homepage:edit' },
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ const routes = [
|
|||||||
component: () => import('../views/settings/PaymentConfig.vue'),
|
component: () => import('../views/settings/PaymentConfig.vue'),
|
||||||
meta: { title: '支付配置', permission: 'payment_config' }
|
meta: { title: '支付配置', permission: 'payment_config' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'chunk-upload-cleanup',
|
||||||
|
name: 'ChunkUploadCleanup',
|
||||||
|
component: () => import('../views/settings/ChunkUploadCleanup.vue'),
|
||||||
|
meta: { title: '分片上传清理', permission: 'site:manage' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'yuheng-cloud-accounts',
|
||||||
|
name: 'YuhengCloudAccountManage',
|
||||||
|
component: () => import('../views/settings/YuhengCloudAccountManage.vue'),
|
||||||
|
meta: { title: '宇恒云账号', permission: 'yuheng_cloud:manage' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'sites',
|
path: 'sites',
|
||||||
name: 'Sites',
|
name: 'Sites',
|
||||||
|
|||||||
152
admin/src/utils/siteAssetResumableUpload.js
Normal file
152
admin/src/utils/siteAssetResumableUpload.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import {
|
||||||
|
abortMultipartUpload,
|
||||||
|
completeMultipartUpload,
|
||||||
|
getMultipartUploadStatus,
|
||||||
|
initMultipartUpload,
|
||||||
|
putMultipartChunk,
|
||||||
|
uploadSiteAsset
|
||||||
|
} from '../api/admin'
|
||||||
|
|
||||||
|
const CHUNK_THRESHOLD = 8 * 1024 * 1024
|
||||||
|
const DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024
|
||||||
|
const UPLOAD_CONCURRENCY = 3
|
||||||
|
|
||||||
|
function fileFingerprint(file) {
|
||||||
|
return `${file.name}\t${file.size}\t${file.lastModified}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageKey(siteId) {
|
||||||
|
return `yh_resumable_asset_${siteId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站点资源上传:小于阈值整包 POST;大于等于阈值走分片 + sessionStorage 断点续传(同文件同站点刷新后可续传)。
|
||||||
|
* @param {string} siteId
|
||||||
|
* @param {File|Blob} file
|
||||||
|
* @param {{ folder?: string, downloadable?: boolean, preserveFilename?: boolean }} opts
|
||||||
|
* @param {{ onProgress?: (p: { percent: number, loaded: number, total: number }) => void }} callbacks
|
||||||
|
*/
|
||||||
|
export async function uploadSiteAssetWithResume(siteId, file, opts = {}, callbacks = {}) {
|
||||||
|
const onProgress = typeof callbacks.onProgress === 'function' ? callbacks.onProgress : null
|
||||||
|
const total = file.size
|
||||||
|
if (total <= CHUNK_THRESHOLD) {
|
||||||
|
if (onProgress) onProgress({ percent: 0, loaded: 0, total })
|
||||||
|
const res = await uploadSiteAsset(siteId, file, opts)
|
||||||
|
if (onProgress) onProgress({ percent: 100, loaded: total, total })
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
const fp = fileFingerprint(file)
|
||||||
|
const key = storageKey(siteId)
|
||||||
|
let uploadId = null
|
||||||
|
let chunkSize = DEFAULT_CHUNK_SIZE
|
||||||
|
let totalChunks = 0
|
||||||
|
const received = new Set()
|
||||||
|
|
||||||
|
let cached = null
|
||||||
|
try {
|
||||||
|
cached = JSON.parse(sessionStorage.getItem(key) || 'null')
|
||||||
|
} catch (_) {
|
||||||
|
cached = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cached && cached.fingerprint === fp && cached.upload_id) {
|
||||||
|
try {
|
||||||
|
const st = await getMultipartUploadStatus(siteId, cached.upload_id)
|
||||||
|
if (
|
||||||
|
st.total_size === file.size &&
|
||||||
|
st.original_filename === file.name &&
|
||||||
|
typeof st.chunk_size === 'number' &&
|
||||||
|
st.chunk_size > 0
|
||||||
|
) {
|
||||||
|
uploadId = cached.upload_id
|
||||||
|
chunkSize = st.chunk_size
|
||||||
|
totalChunks = st.total_chunks
|
||||||
|
for (const i of st.received_chunks || []) {
|
||||||
|
received.add(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
uploadId = null
|
||||||
|
received.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!uploadId) {
|
||||||
|
const init = await initMultipartUpload(siteId, {
|
||||||
|
filename: file.name,
|
||||||
|
total_size: file.size,
|
||||||
|
chunk_size: DEFAULT_CHUNK_SIZE,
|
||||||
|
folder: opts.folder || '',
|
||||||
|
downloadable: Boolean(opts.downloadable),
|
||||||
|
preserve_filename: Boolean(opts.preserveFilename)
|
||||||
|
})
|
||||||
|
uploadId = init.upload_id
|
||||||
|
chunkSize = init.chunk_size || DEFAULT_CHUNK_SIZE
|
||||||
|
totalChunks = init.total_chunks
|
||||||
|
sessionStorage.setItem(key, JSON.stringify({ fingerprint: fp, upload_id: uploadId, total_chunks: totalChunks }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = []
|
||||||
|
for (let i = 0; i < totalChunks; i++) {
|
||||||
|
if (!received.has(i)) missing.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkByteLength = (idx) => {
|
||||||
|
const start = idx * chunkSize
|
||||||
|
const end = Math.min(start + chunkSize, file.size)
|
||||||
|
return end - start
|
||||||
|
}
|
||||||
|
|
||||||
|
let uploadedBytes = 0
|
||||||
|
for (let i = 0; i < totalChunks; i++) {
|
||||||
|
if (received.has(i)) uploadedBytes += chunkByteLength(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportProgress = () => {
|
||||||
|
if (!onProgress) return
|
||||||
|
const pct = total <= 0 ? 100 : Math.min(99, Math.round((uploadedBytes / total) * 100))
|
||||||
|
onProgress({ percent: pct, loaded: uploadedBytes, total })
|
||||||
|
}
|
||||||
|
reportProgress()
|
||||||
|
|
||||||
|
const queue = [...missing]
|
||||||
|
const worker = async () => {
|
||||||
|
while (queue.length) {
|
||||||
|
const idx = queue.shift()
|
||||||
|
if (idx === undefined) break
|
||||||
|
const start = idx * chunkSize
|
||||||
|
const end = Math.min(start + chunkSize, file.size)
|
||||||
|
const blob = file.slice(start, end)
|
||||||
|
await putMultipartChunk(siteId, uploadId, idx, blob)
|
||||||
|
uploadedBytes += end - start
|
||||||
|
reportProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, Math.max(1, missing.length)) }, () => worker()))
|
||||||
|
|
||||||
|
const done = await completeMultipartUpload(siteId, uploadId)
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(key)
|
||||||
|
} catch (_) {}
|
||||||
|
if (onProgress) onProgress({ percent: 100, loaded: total, total })
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 放弃当前站点的分片会话(可选) */
|
||||||
|
export async function abortSiteAssetResumable(siteId) {
|
||||||
|
const key = storageKey(siteId)
|
||||||
|
let cached = null
|
||||||
|
try {
|
||||||
|
cached = JSON.parse(sessionStorage.getItem(key) || 'null')
|
||||||
|
} catch (_) {
|
||||||
|
cached = null
|
||||||
|
}
|
||||||
|
if (!cached?.upload_id) return
|
||||||
|
try {
|
||||||
|
await abortMultipartUpload(siteId, cached.upload_id)
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(key)
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
|
|
||||||
<!-- 上传前选择是否可下载 -->
|
<!-- 上传前选择是否可下载 -->
|
||||||
<el-dialog v-model="uploadDialogVisible" title="上传文件" width="440px" :close-on-click-modal="false">
|
<el-dialog v-model="uploadDialogVisible" title="上传文件" width="440px" :close-on-click-modal="false">
|
||||||
|
<p class="upload-resume-hint">≥8MB 将自动分片上传;中断后<strong>同一文件</strong>再次选择上传可续传(勿改文件名/大小)。</p>
|
||||||
<el-form label-width="112px">
|
<el-form label-width="112px">
|
||||||
<el-form-item label="当前目录">
|
<el-form-item label="当前目录">
|
||||||
<span>{{ currentPath || '根目录' }}</span>
|
<span>{{ currentPath || '根目录' }}</span>
|
||||||
@@ -68,6 +69,9 @@
|
|||||||
<el-form-item label="允许下载">
|
<el-form-item label="允许下载">
|
||||||
<el-switch v-model="uploadDownloadable" />
|
<el-switch v-model="uploadDownloadable" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item v-if="uploading && uploadPercent > 0" label="进度">
|
||||||
|
<el-progress :percentage="uploadPercent" :stroke-width="16" />
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="uploadDialogVisible = false">取消</el-button>
|
<el-button @click="uploadDialogVisible = false">取消</el-button>
|
||||||
@@ -93,7 +97,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { getSites, getSiteAssets, uploadSiteAsset, deleteSiteAsset, createSiteFolder } from '../../api/admin'
|
import { getSites, getSiteAssets, deleteSiteAsset, createSiteFolder } from '../../api/admin'
|
||||||
|
import { uploadSiteAssetWithResume } from '../../utils/siteAssetResumableUpload'
|
||||||
|
|
||||||
const activeTab = ref('module')
|
const activeTab = ref('module')
|
||||||
const siteId = ref('')
|
const siteId = ref('')
|
||||||
@@ -103,6 +108,7 @@ const subDirs = ref([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const currentPath = ref('')
|
const currentPath = ref('')
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
|
const uploadPercent = ref(0)
|
||||||
const uploadDialogVisible = ref(false)
|
const uploadDialogVisible = ref(false)
|
||||||
const uploadDownloadable = ref(false)
|
const uploadDownloadable = ref(false)
|
||||||
const uploadPreserveFilename = ref(false)
|
const uploadPreserveFilename = ref(false)
|
||||||
@@ -167,12 +173,22 @@ const beforeUpload = (file) => {
|
|||||||
const doUpload = async () => {
|
const doUpload = async () => {
|
||||||
if (!pendingFile.value || !siteId.value) return
|
if (!pendingFile.value || !siteId.value) return
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
|
uploadPercent.value = 0
|
||||||
try {
|
try {
|
||||||
await uploadSiteAsset(siteId.value, pendingFile.value, {
|
await uploadSiteAssetWithResume(
|
||||||
|
siteId.value,
|
||||||
|
pendingFile.value,
|
||||||
|
{
|
||||||
folder: currentPath.value || undefined,
|
folder: currentPath.value || undefined,
|
||||||
downloadable: uploadDownloadable.value,
|
downloadable: uploadDownloadable.value,
|
||||||
preserveFilename: uploadPreserveFilename.value
|
preserveFilename: uploadPreserveFilename.value
|
||||||
})
|
},
|
||||||
|
{
|
||||||
|
onProgress: ({ percent }) => {
|
||||||
|
uploadPercent.value = percent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
ElMessage.success('上传成功')
|
ElMessage.success('上传成功')
|
||||||
uploadDialogVisible.value = false
|
uploadDialogVisible.value = false
|
||||||
pendingFile.value = null
|
pendingFile.value = null
|
||||||
@@ -181,6 +197,7 @@ const doUpload = async () => {
|
|||||||
ElMessage.error(e.response?.data?.error || e.message || '上传失败')
|
ElMessage.error(e.response?.data?.error || e.message || '上传失败')
|
||||||
} finally {
|
} finally {
|
||||||
uploading.value = false
|
uploading.value = false
|
||||||
|
uploadPercent.value = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,4 +247,10 @@ onMounted(() => fetchSites().then(() => fetchList()))
|
|||||||
.breadcrumb-wrap { margin-top: 12px; }
|
.breadcrumb-wrap { margin-top: 12px; }
|
||||||
.subdirs { margin-top: 8px; font-size: 13px; color: #666; }
|
.subdirs { margin-top: 8px; font-size: 13px; color: #666; }
|
||||||
.subdirs .label { margin-right: 8px; }
|
.subdirs .label { margin-right: 8px; }
|
||||||
|
.upload-resume-hint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #606266;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
91
admin/src/views/settings/ChunkUploadCleanup.vue
Normal file
91
admin/src/views/settings/ChunkUploadCleanup.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chunk-upload-cleanup">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<span>分片上传临时目录清理</span>
|
||||||
|
</template>
|
||||||
|
<p class="hint">
|
||||||
|
大文件分片上传时,未完成合并的会话保存在上传目录下的 <code>.chunk-uploads</code>。超过下方「保留时长」的目录会被定期删除。未在后台保存时,可使用环境变量
|
||||||
|
<code>YH_CHUNK_UPLOAD_MAX_AGE_HOURS</code>、<code>YH_CHUNK_UPLOAD_SWEEP_MINUTES</code>(保存后台配置后优先生效)。
|
||||||
|
</p>
|
||||||
|
<el-form v-if="canEdit" :model="form" label-width="140px" style="max-width: 520px">
|
||||||
|
<el-form-item label="保留时长(小时)">
|
||||||
|
<el-input-number v-model="form.max_age_hours" :min="6" :max="336" :step="6" controls-position="right" />
|
||||||
|
<span class="form-tip">6~336,默认 72;超过此时长未合并的会话视为非活动</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="扫描间隔(分钟)">
|
||||||
|
<el-input-number v-model="form.sweep_minutes" :min="5" :max="1440" :step="5" controls-position="right" />
|
||||||
|
<span class="form-tip">5~1440,默认 60;服务端按此频率检查是否需清扫</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" @click="handleSave">保存</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-alert v-else type="warning" title="无权限" description="需要「站点管理」权限。" :closable="false" />
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getChunkUploadCleanup, updateChunkUploadCleanup } from '../../api/admin'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const loading = ref(false)
|
||||||
|
const form = reactive({
|
||||||
|
max_age_hours: 72,
|
||||||
|
sweep_minutes: 60
|
||||||
|
})
|
||||||
|
|
||||||
|
const canEdit = computed(() => authStore.hasPermission('site:manage'))
|
||||||
|
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
if (!canEdit.value) return
|
||||||
|
try {
|
||||||
|
const res = await getChunkUploadCleanup()
|
||||||
|
form.max_age_hours = Number(res.max_age_hours) || 72
|
||||||
|
form.sweep_minutes = Number(res.sweep_minutes) || 60
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || '获取配置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await updateChunkUploadCleanup({
|
||||||
|
max_age_hours: form.max_age_hours,
|
||||||
|
sweep_minutes: form.sweep_minutes
|
||||||
|
})
|
||||||
|
ElMessage.success('保存成功,新参数在下次清扫周期生效')
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.error || e.message || '保存失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchConfig)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chunk-upload-cleanup {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
line-height: 1.65;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
.form-tip {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
154
admin/src/views/settings/YuhengCloudAccountManage.vue
Normal file
154
admin/src/views/settings/YuhengCloudAccountManage.vue
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<div class="yh-cloud-accounts">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<span>宇恒云账号管理</span>
|
||||||
|
</template>
|
||||||
|
<p class="hint">
|
||||||
|
调用云端
|
||||||
|
<code>POST /register</code>(默认
|
||||||
|
<code>http://www.cloud.yuxindazhineng.com:3001/register</code>,可通过环境变量
|
||||||
|
<code>YH_CLOUD_REGISTER_URL</code> 覆盖)。成功后在 Mongo 集合
|
||||||
|
<code>yuheng_cloud_register_records</code> 写入一条记录,<strong>仅保存账号与密码</strong>;邮箱仅用于提交云端。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<el-form v-if="canEdit" :model="form" :rules="rules" ref="formRef" label-width="88px" class="add-form">
|
||||||
|
<el-form-item label="用户名" prop="username">
|
||||||
|
<el-input v-model="form.username" placeholder="云端 username" clearable style="max-width: 360px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码" prop="password">
|
||||||
|
<el-input v-model="form.password" type="password" placeholder="云端 password" show-password clearable style="max-width: 360px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="邮箱" prop="email">
|
||||||
|
<el-input v-model="form.email" placeholder="云端必填 email,不入库" clearable style="max-width: 360px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submit">提交注册</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-alert v-else type="warning" title="无权限" description="需要「宇恒云账号管理」权限,请在角色权限中为当前角色勾选。" :closable="false" />
|
||||||
|
|
||||||
|
<el-divider v-if="canEdit" />
|
||||||
|
|
||||||
|
<el-table v-if="canEdit" :data="list" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="username" label="账号" min-width="140" />
|
||||||
|
<el-table-column label="密码" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="pwd-mask">{{ row._showPwd ? row.password : '••••••••' }}</span>
|
||||||
|
<el-button link type="primary" size="small" @click="row._showPwd = !row._showPwd">
|
||||||
|
{{ row._showPwd ? '隐藏' : '显示' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="created_at" label="记录时间" width="200" />
|
||||||
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-if="canEdit"
|
||||||
|
v-model:current-page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
style="margin-top: 16px"
|
||||||
|
@current-change="fetchList"
|
||||||
|
@size-change="fetchList"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { createYuhengCloudAccount, listYuhengCloudAccounts } from '../../api/admin'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const canEdit = computed(() => authStore.hasPermission('yuheng_cloud:manage'))
|
||||||
|
|
||||||
|
const formRef = ref(null)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
email: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||||
|
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||||
|
email: [{ required: true, message: '请输入邮箱(提交云端)', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchList = async () => {
|
||||||
|
if (!canEdit.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await listYuhengCloudAccounts({ page: page.value, page_size: pageSize.value })
|
||||||
|
const rows = (res.list || []).map((r) => ({ ...r, _showPwd: false }))
|
||||||
|
list.value = rows
|
||||||
|
total.value = res.total ?? 0
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || '加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await createYuhengCloudAccount({
|
||||||
|
username: form.username.trim(),
|
||||||
|
password: form.password,
|
||||||
|
email: form.email.trim()
|
||||||
|
})
|
||||||
|
ElMessage.success('云端注册成功,已写入本地记录')
|
||||||
|
form.username = ''
|
||||||
|
form.password = ''
|
||||||
|
form.email = ''
|
||||||
|
formRef.value?.resetFields()
|
||||||
|
fetchList()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.error || e.message || '提交失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (canEdit.value) fetchList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.yh-cloud-accounts {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
line-height: 1.65;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
.add-form {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
.pwd-mask {
|
||||||
|
margin-right: 8px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -43,7 +43,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, watch } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { getSites, getSiteAssets, uploadSiteAsset, deleteSiteAsset } from '../../api/admin'
|
import { getSites, getSiteAssets, deleteSiteAsset } from '../../api/admin'
|
||||||
|
import { uploadSiteAssetWithResume } from '../../utils/siteAssetResumableUpload'
|
||||||
|
|
||||||
const siteId = ref('')
|
const siteId = ref('')
|
||||||
const sites = ref([])
|
const sites = ref([])
|
||||||
@@ -87,7 +88,7 @@ watch(siteId, fetchList)
|
|||||||
const beforeUpload = async (file) => {
|
const beforeUpload = async (file) => {
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
try {
|
try {
|
||||||
await uploadSiteAsset(siteId.value, file)
|
await uploadSiteAssetWithResume(siteId.value, file, {})
|
||||||
ElMessage.success('上传成功')
|
ElMessage.success('上传成功')
|
||||||
fetchList()
|
fetchList()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name yuheng.yuxindazhineng.com;
|
server_name yuheng.yuxindazhineng.com;
|
||||||
|
# 与宿主机 yuheng.host.conf 一致,避免后台大文件上传被默认 1m 拒绝
|
||||||
|
client_max_body_size 800m;
|
||||||
# 若使用 HTTPS,取消下面注释并挂载证书到 /etc/nginx/ssl/
|
# 若使用 HTTPS,取消下面注释并挂载证书到 /etc/nginx/ssl/
|
||||||
# listen 443 ssl;
|
# listen 443 ssl;
|
||||||
# ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
# ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||||||
@@ -38,8 +40,9 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_connect_timeout 75s;
|
proxy_connect_timeout 75s;
|
||||||
proxy_send_timeout 75s;
|
client_body_timeout 0;
|
||||||
proxy_read_timeout 75s;
|
proxy_send_timeout 86400s;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 75s;
|
||||||
|
client_body_timeout 0;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 尾斜杠形式:proxy_pass 带 / 会去掉 /admin 前缀,上游收到 /assets/…、/index.html 等
|
# 尾斜杠形式:proxy_pass 带 / 会去掉 /admin 前缀,上游收到 /assets/…、/index.html 等
|
||||||
|
|||||||
@@ -77,8 +77,10 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_connect_timeout 75s;
|
proxy_connect_timeout 75s;
|
||||||
proxy_send_timeout 300s;
|
# 大文件上传:client_body_timeout=0 表示不按时间切断读 body(见 ngx_http_core_module);proxy_* 为反代到 Go 的读写等待上限
|
||||||
proxy_read_timeout 300s;
|
client_body_timeout 0;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
163
server/handlers/chunk_upload_cleanup_config.go
Normal file
163
server/handlers/chunk_upload_cleanup_config.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"yh_web/server/config"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
)
|
||||||
|
|
||||||
|
const chunkCleanupConfigID = "chunk_upload_cleanup"
|
||||||
|
|
||||||
|
type chunkCleanupConfigDoc struct {
|
||||||
|
MaxAgeHours float64 `bson:"max_age_hours" json:"max_age_hours"`
|
||||||
|
SweepMinutes int `bson:"sweep_minutes" json:"sweep_minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxAgeFromEnv() time.Duration {
|
||||||
|
h := strings.TrimSpace(os.Getenv("YH_CHUNK_UPLOAD_MAX_AGE_HOURS"))
|
||||||
|
if h == "" {
|
||||||
|
return 72 * time.Hour
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(h, 64)
|
||||||
|
if err != nil || v < 6 {
|
||||||
|
return 72 * time.Hour
|
||||||
|
}
|
||||||
|
return time.Duration(v * float64(time.Hour))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sweepFromEnv() time.Duration {
|
||||||
|
m := strings.TrimSpace(os.Getenv("YH_CHUNK_UPLOAD_SWEEP_MINUTES"))
|
||||||
|
if m == "" {
|
||||||
|
return time.Hour
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(m)
|
||||||
|
if err != nil || v < 5 {
|
||||||
|
return time.Hour
|
||||||
|
}
|
||||||
|
return time.Duration(v) * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMaxAgeHours(h float64) time.Duration {
|
||||||
|
if h < 6 {
|
||||||
|
return 6 * time.Hour
|
||||||
|
}
|
||||||
|
if h > 336 {
|
||||||
|
return 336 * time.Hour
|
||||||
|
}
|
||||||
|
return time.Duration(h * float64(time.Hour))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSweepMinutes(m int) time.Duration {
|
||||||
|
if m < 5 {
|
||||||
|
return 5 * time.Minute
|
||||||
|
}
|
||||||
|
if m > 1440 {
|
||||||
|
return 1440 * time.Minute
|
||||||
|
}
|
||||||
|
return time.Duration(m) * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadChunkCleanupParameters 优先读 MongoDB system_config;无文档时用环境变量;用于定时清扫
|
||||||
|
func loadChunkCleanupParameters() (maxAge time.Duration, sweepEvery time.Duration) {
|
||||||
|
db := config.GetDB(config.DBName)
|
||||||
|
if db != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var doc chunkCleanupConfigDoc
|
||||||
|
err := db.Collection("system_config").FindOne(ctx, bson.M{"_id": chunkCleanupConfigID}).Decode(&doc)
|
||||||
|
if err == nil && doc.MaxAgeHours >= 6 && doc.SweepMinutes >= 5 {
|
||||||
|
return normalizeMaxAgeHours(doc.MaxAgeHours), normalizeSweepMinutes(doc.SweepMinutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxAgeFromEnv(), sweepFromEnv()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChunkUploadCleanupConfig 后台读取当前保存的配置(无文档时返回默认值)
|
||||||
|
func GetChunkUploadCleanupConfig(c *gin.Context) {
|
||||||
|
db := config.GetDB(config.DBName)
|
||||||
|
if db == nil {
|
||||||
|
c.JSON(http.StatusOK, chunkCleanupConfigDoc{
|
||||||
|
MaxAgeHours: 72,
|
||||||
|
SweepMinutes: 60,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var doc chunkCleanupConfigDoc
|
||||||
|
err := db.Collection("system_config").FindOne(ctx, bson.M{"_id": chunkCleanupConfigID}).Decode(&doc)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
c.JSON(http.StatusOK, chunkCleanupConfigDoc{
|
||||||
|
MaxAgeHours: 72,
|
||||||
|
SweepMinutes: 60,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if doc.MaxAgeHours < 6 {
|
||||||
|
doc.MaxAgeHours = 72
|
||||||
|
}
|
||||||
|
if doc.SweepMinutes < 5 {
|
||||||
|
doc.SweepMinutes = 60
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChunkUploadCleanupUpdateInput 后台保存
|
||||||
|
type ChunkUploadCleanupUpdateInput struct {
|
||||||
|
MaxAgeHours float64 `json:"max_age_hours" binding:"required"`
|
||||||
|
SweepMinutes int `json:"sweep_minutes" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateChunkUploadCleanupConfig 保存分片临时目录保留时长与扫描间隔
|
||||||
|
func UpdateChunkUploadCleanupConfig(c *gin.Context) {
|
||||||
|
var input ChunkUploadCleanupUpdateInput
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if input.MaxAgeHours < 6 || input.MaxAgeHours > 336 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "保留时长须在 6~336 小时之间"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if input.SweepMinutes < 5 || input.SweepMinutes > 1440 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "扫描间隔须在 5~1440 分钟之间"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := config.GetDB(config.DBName)
|
||||||
|
if db == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "数据库未连接,无法保存"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
coll := db.Collection("system_config")
|
||||||
|
set := bson.M{
|
||||||
|
"_id": chunkCleanupConfigID,
|
||||||
|
"max_age_hours": input.MaxAgeHours,
|
||||||
|
"sweep_minutes": input.SweepMinutes,
|
||||||
|
"updated_at": time.Now().Format(time.RFC3339),
|
||||||
|
"updated_by_hint": "admin",
|
||||||
|
}
|
||||||
|
opts := options.UpdateOne().SetUpsert(true)
|
||||||
|
_, err := coll.UpdateOne(ctx, bson.M{"_id": chunkCleanupConfigID}, bson.M{"$set": set}, opts)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "配置已保存"})
|
||||||
|
}
|
||||||
@@ -207,6 +207,42 @@ func ServePromotionMedia(c *gin.Context) {
|
|||||||
c.File(fullPath)
|
c.File(fullPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// computeSiteUploadDest 与整文件上传、分片合并完成时一致的目标路径(非 preserve 时 saveName 含当前时间戳)
|
||||||
|
func computeSiteUploadDest(siteID, folder, originalFilename string, preserve bool) (relPath, destPath string, errMsg string) {
|
||||||
|
name := originalFilename
|
||||||
|
ext := filepath.Ext(name)
|
||||||
|
nameNoExt := strings.TrimSuffix(name, ext)
|
||||||
|
var saveName string
|
||||||
|
if preserve {
|
||||||
|
saveName = filepath.Base(name)
|
||||||
|
if saveName == "." || saveName == string(filepath.Separator) || saveName == "" {
|
||||||
|
return "", "", "无效的文件名"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(ext) == 0 {
|
||||||
|
saveName = nameNoExt + "_" + time.Now().Format("20060102150405")
|
||||||
|
} else {
|
||||||
|
saveName = nameNoExt + "_" + time.Now().Format("20060102150405") + ext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
folderClean := ""
|
||||||
|
if folder != "" {
|
||||||
|
folderClean = filepath.ToSlash(filepath.Clean(folder))
|
||||||
|
if strings.HasPrefix(folderClean, "../") || strings.Contains(folderClean, "/../") {
|
||||||
|
return "", "", "无效的目录路径"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if folderClean != "" {
|
||||||
|
relPath = filepath.ToSlash(filepath.Join("sites", siteID, folderClean, saveName))
|
||||||
|
} else {
|
||||||
|
relPath = filepath.ToSlash(filepath.Join("sites", siteID, saveName))
|
||||||
|
}
|
||||||
|
destPath = filepath.Join(getUploadDir(), filepath.FromSlash(relPath))
|
||||||
|
return relPath, destPath, ""
|
||||||
|
}
|
||||||
|
|
||||||
// UploadSiteAsset 上传功能模块/文件;form 可选:folder(当前目录相对路径)、downloadable(true/false)、preserve_filename(true 时保持原名并覆盖同路径旧文件,用于首页推广视频固定 URL)
|
// UploadSiteAsset 上传功能模块/文件;form 可选:folder(当前目录相对路径)、downloadable(true/false)、preserve_filename(true 时保持原名并覆盖同路径旧文件,用于首页推广视频固定 URL)
|
||||||
func UploadSiteAsset(c *gin.Context) {
|
func UploadSiteAsset(c *gin.Context) {
|
||||||
siteID := c.Param("site_id")
|
siteID := c.Param("site_id")
|
||||||
@@ -225,40 +261,11 @@ func UploadSiteAsset(c *gin.Context) {
|
|||||||
downloadable := c.PostForm("downloadable") == "true" || c.PostForm("downloadable") == "1"
|
downloadable := c.PostForm("downloadable") == "true" || c.PostForm("downloadable") == "1"
|
||||||
preserve := c.PostForm("preserve_filename") == "true" || c.PostForm("preserve_filename") == "1"
|
preserve := c.PostForm("preserve_filename") == "true" || c.PostForm("preserve_filename") == "1"
|
||||||
|
|
||||||
name := file.Filename
|
relPath, destPath, errMsg := computeSiteUploadDest(siteID, folder, file.Filename, preserve)
|
||||||
ext := filepath.Ext(name)
|
if errMsg != "" {
|
||||||
nameNoExt := strings.TrimSuffix(name, ext)
|
c.JSON(http.StatusBadRequest, gin.H{"error": errMsg})
|
||||||
var saveName string
|
|
||||||
if preserve {
|
|
||||||
saveName = filepath.Base(name)
|
|
||||||
if saveName == "." || saveName == string(filepath.Separator) || saveName == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件名"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
if len(ext) == 0 {
|
|
||||||
saveName = nameNoExt + "_" + time.Now().Format("20060102150405")
|
|
||||||
} else {
|
|
||||||
saveName = nameNoExt + "_" + time.Now().Format("20060102150405") + ext
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
folderClean := ""
|
|
||||||
if folder != "" {
|
|
||||||
folderClean = filepath.ToSlash(filepath.Clean(folder))
|
|
||||||
if strings.HasPrefix(folderClean, "../") || strings.Contains(folderClean, "/../") {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的目录路径"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var relPath string
|
|
||||||
if folderClean != "" {
|
|
||||||
relPath = filepath.ToSlash(filepath.Join("sites", siteID, folderClean, saveName))
|
|
||||||
} else {
|
|
||||||
relPath = filepath.ToSlash(filepath.Join("sites", siteID, saveName))
|
|
||||||
}
|
|
||||||
destPath := filepath.Join(getUploadDir(), filepath.FromSlash(relPath))
|
|
||||||
|
|
||||||
if preserve {
|
if preserve {
|
||||||
ctxDel, cancelDel := context.WithTimeout(context.Background(), 8*time.Second)
|
ctxDel, cancelDel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||||
|
|||||||
501
server/handlers/multipart_upload.go
Normal file
501
server/handlers/multipart_upload.go
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"yh_web/server/config"
|
||||||
|
"yh_web/server/pkg/logger"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 与 Nginx client_max_body_size 对齐;分片单请求仅 chunk_size 字节量级
|
||||||
|
const maxMultipartTotalSize = int64(800 << 20)
|
||||||
|
const defaultChunkSize = int64(4 << 20)
|
||||||
|
const minChunkSize = int64(1 << 20)
|
||||||
|
const maxChunkSize = int64(32 << 20)
|
||||||
|
|
||||||
|
type chunkSessionMeta struct {
|
||||||
|
SiteID string `json:"site_id"`
|
||||||
|
OriginalFilename string `json:"original_filename"`
|
||||||
|
TotalSize int64 `json:"total_size"`
|
||||||
|
ChunkSize int64 `json:"chunk_size"`
|
||||||
|
TotalChunks int `json:"total_chunks"`
|
||||||
|
Folder string `json:"folder"`
|
||||||
|
Downloadable bool `json:"downloadable"`
|
||||||
|
PreserveFilename bool `json:"preserve_filename"`
|
||||||
|
CreatedUnix int64 `json:"created_unix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func chunkSessionsRoot() string {
|
||||||
|
return filepath.Join(getUploadDir(), ".chunk-uploads")
|
||||||
|
}
|
||||||
|
|
||||||
|
func chunkSessionDir(uploadID string) string {
|
||||||
|
return filepath.Join(chunkSessionsRoot(), uploadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func metaPath(uploadID string) string {
|
||||||
|
return filepath.Join(chunkSessionDir(uploadID), "meta.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validUploadID(uploadID string) bool {
|
||||||
|
if len(uploadID) != 24 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range uploadID {
|
||||||
|
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := bson.ObjectIDFromHex(uploadID)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readChunkMeta(uploadID string) (*chunkSessionMeta, error) {
|
||||||
|
data, err := os.ReadFile(metaPath(uploadID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var m chunkSessionMeta
|
||||||
|
if err := json.Unmarshal(data, &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func chunkExpectedSize(meta *chunkSessionMeta, index int) int64 {
|
||||||
|
if index < 0 || index >= meta.TotalChunks {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
start := int64(index) * meta.ChunkSize
|
||||||
|
end := start + meta.ChunkSize
|
||||||
|
if end > meta.TotalSize {
|
||||||
|
end = meta.TotalSize
|
||||||
|
}
|
||||||
|
return end - start
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitMultipartUpload 创建分片会话(断点续传第一步)
|
||||||
|
func InitMultipartUpload(c *gin.Context) {
|
||||||
|
siteID := c.Param("site_id")
|
||||||
|
if siteID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Filename string `json:"filename" binding:"required"`
|
||||||
|
TotalSize int64 `json:"total_size" binding:"required"`
|
||||||
|
ChunkSize int64 `json:"chunk_size"`
|
||||||
|
Folder string `json:"folder"`
|
||||||
|
Downloadable bool `json:"downloadable"`
|
||||||
|
PreserveFilename bool `json:"preserve_filename"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 filename、total_size"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.TotalSize <= 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "文件大小无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.TotalSize > maxMultipartTotalSize {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "文件超过当前站点允许的最大体积(800MB)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cs := body.ChunkSize
|
||||||
|
if cs <= 0 {
|
||||||
|
cs = defaultChunkSize
|
||||||
|
}
|
||||||
|
if cs < minChunkSize || cs > maxChunkSize {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "chunk_size 须在 1MB~32MB 之间"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
totalChunks := int((body.TotalSize + cs - 1) / cs)
|
||||||
|
if totalChunks <= 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "分片数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
folder := strings.TrimSpace(body.Folder)
|
||||||
|
if folder != "" {
|
||||||
|
fc := filepath.ToSlash(filepath.Clean(folder))
|
||||||
|
if strings.HasPrefix(fc, "../") || strings.Contains(fc, "/../") {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的目录路径"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadID := bson.NewObjectID().Hex()
|
||||||
|
dir := chunkSessionDir(uploadID)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建临时目录失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meta := chunkSessionMeta{
|
||||||
|
SiteID: siteID,
|
||||||
|
OriginalFilename: body.Filename,
|
||||||
|
TotalSize: body.TotalSize,
|
||||||
|
ChunkSize: cs,
|
||||||
|
TotalChunks: totalChunks,
|
||||||
|
Folder: folder,
|
||||||
|
Downloadable: body.Downloadable,
|
||||||
|
PreserveFilename: body.PreserveFilename,
|
||||||
|
CreatedUnix: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(meta)
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "meta.json"), raw, 0644); err != nil {
|
||||||
|
_ = os.RemoveAll(dir)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "写入会话失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"upload_id": uploadID,
|
||||||
|
"chunk_size": cs,
|
||||||
|
"total_chunks": totalChunks,
|
||||||
|
"received_chunks": []int{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MultipartUploadStatus 返回已收到的分片下标(用于续传)
|
||||||
|
func MultipartUploadStatus(c *gin.Context) {
|
||||||
|
siteID := c.Param("site_id")
|
||||||
|
uploadID := c.Param("upload_id")
|
||||||
|
if siteID == "" || !validUploadID(uploadID) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meta, err := readChunkMeta(uploadID)
|
||||||
|
if err != nil || meta.SiteID != siteID {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "上传会话不存在或已过期"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir := chunkSessionDir(uploadID)
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取会话失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
received := make([]int, 0, meta.TotalChunks)
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || e.Name() == "meta.json" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil || idx < 0 || idx >= meta.TotalChunks {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exp := chunkExpectedSize(meta, idx)
|
||||||
|
if exp >= 0 && info.Size() == exp {
|
||||||
|
received = append(received, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Ints(received)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"upload_id": uploadID,
|
||||||
|
"total_chunks": meta.TotalChunks,
|
||||||
|
"total_size": meta.TotalSize,
|
||||||
|
"chunk_size": meta.ChunkSize,
|
||||||
|
"received_chunks": received,
|
||||||
|
"original_filename": meta.OriginalFilename,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutMultipartChunk 上传单个分片(二进制 body,长度须与分片大小一致)
|
||||||
|
func PutMultipartChunk(c *gin.Context) {
|
||||||
|
siteID := c.Param("site_id")
|
||||||
|
uploadID := c.Param("upload_id")
|
||||||
|
chunkStr := c.Param("chunk_index")
|
||||||
|
if siteID == "" || !validUploadID(uploadID) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chunkIndex, err := strconv.Atoi(chunkStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的分片序号"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meta, err := readChunkMeta(uploadID)
|
||||||
|
if err != nil || meta.SiteID != siteID {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "上传会话不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expected := chunkExpectedSize(meta, chunkIndex)
|
||||||
|
if expected < 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "分片序号越界"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkFile := filepath.Join(chunkSessionDir(uploadID), strconv.Itoa(chunkIndex))
|
||||||
|
if fi, err := os.Stat(chunkFile); err == nil && fi.Size() == expected {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "分片已存在", "chunk_index": chunkIndex, "size": expected})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp := chunkFile + ".part"
|
||||||
|
f, err := os.Create(tmp)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建临时文件失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := io.Copy(f, io.LimitReader(c.Request.Body, expected+1))
|
||||||
|
_ = f.Close()
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "读取分片失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n != expected {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "分片大小不符"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, chunkFile); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存分片失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "分片已保存", "chunk_index": chunkIndex, "size": expected})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteMultipartUpload 合并分片并写入 site_assets
|
||||||
|
func CompleteMultipartUpload(c *gin.Context) {
|
||||||
|
siteID := c.Param("site_id")
|
||||||
|
uploadID := c.Param("upload_id")
|
||||||
|
if siteID == "" || !validUploadID(uploadID) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meta, err := readChunkMeta(uploadID)
|
||||||
|
if err != nil || meta.SiteID != siteID {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "上传会话不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir := chunkSessionDir(uploadID)
|
||||||
|
for i := 0; i < meta.TotalChunks; i++ {
|
||||||
|
p := filepath.Join(dir, strconv.Itoa(i))
|
||||||
|
fi, err := os.Stat(p)
|
||||||
|
if err != nil || fi.IsDir() {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "分片未齐,无法合并", "missing_chunk": i})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fi.Size() != chunkExpectedSize(meta, i) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "分片大小异常", "chunk_index": i})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath, destPath, errMsg := computeSiteUploadDest(siteID, meta.Folder, meta.OriginalFilename, meta.PreserveFilename)
|
||||||
|
if errMsg != "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": errMsg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.PreserveFilename {
|
||||||
|
ctxDel, cancelDel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
||||||
|
defer cancelDel()
|
||||||
|
coll := config.GetDB(config.DBName).Collection("site_assets")
|
||||||
|
_, _ = coll.DeleteMany(ctxDel, bson.M{"site_id": siteID, "file_path": relPath})
|
||||||
|
_ = os.Remove(destPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseDir := filepath.Dir(destPath)
|
||||||
|
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dst, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目标文件失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := 0; i < meta.TotalChunks; i++ {
|
||||||
|
srcPath := filepath.Join(dir, strconv.Itoa(i))
|
||||||
|
src, err := os.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
_ = dst.Close()
|
||||||
|
_ = os.Remove(destPath)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "打开分片失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = io.Copy(dst, src)
|
||||||
|
_ = src.Close()
|
||||||
|
if err != nil {
|
||||||
|
_ = dst.Close()
|
||||||
|
_ = os.Remove(destPath)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "合并分片失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = dst.Close()
|
||||||
|
|
||||||
|
fi, err := os.Stat(destPath)
|
||||||
|
if err != nil || fi.Size() != meta.TotalSize {
|
||||||
|
_ = os.Remove(destPath)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "合并后大小与声明不符"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, 512)
|
||||||
|
fh, err := os.Open(destPath)
|
||||||
|
var contentType string
|
||||||
|
if err == nil {
|
||||||
|
n, _ := fh.Read(buf)
|
||||||
|
_ = fh.Close()
|
||||||
|
contentType = http.DetectContentType(buf[:n])
|
||||||
|
}
|
||||||
|
if contentType == "" || contentType == "application/octet-stream" {
|
||||||
|
if x := promotionMimeType(filepath.Ext(meta.OriginalFilename)); x != "" {
|
||||||
|
contentType = x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
res, err := config.GetDB(config.DBName).Collection("site_assets").InsertOne(ctx, bson.M{
|
||||||
|
"site_id": siteID,
|
||||||
|
"name": meta.OriginalFilename,
|
||||||
|
"file_path": relPath,
|
||||||
|
"size": meta.TotalSize,
|
||||||
|
"content_type": contentType,
|
||||||
|
"downloadable": meta.Downloadable,
|
||||||
|
"created_at": time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(destPath)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.RemoveAll(dir)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"id": res.InsertedID, "file_path": relPath, "message": "上传成功"})
|
||||||
|
ScheduleTranscodeAfterUpload(siteID, relPath, destPath, res.InsertedID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AbortMultipartUpload 取消分片会话并删除临时文件
|
||||||
|
func AbortMultipartUpload(c *gin.Context) {
|
||||||
|
siteID := c.Param("site_id")
|
||||||
|
uploadID := c.Param("upload_id")
|
||||||
|
if siteID == "" || !validUploadID(uploadID) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meta, err := readChunkMeta(uploadID)
|
||||||
|
if err != nil || meta.SiteID != siteID {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.RemoveAll(chunkSessionDir(uploadID))
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "已取消"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func chunkSessionCreatedAt(uploadID string) time.Time {
|
||||||
|
meta, err := readChunkMeta(uploadID)
|
||||||
|
if err == nil && meta.CreatedUnix > 0 {
|
||||||
|
return time.Unix(meta.CreatedUnix, 0)
|
||||||
|
}
|
||||||
|
fi, err := os.Stat(chunkSessionDir(uploadID))
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return fi.ModTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SweepStaleChunkUploadSessions 删除 {UPLOAD_DIR}/.chunk-uploads 下超过 staleChunkMaxAge 的会话目录
|
||||||
|
func SweepStaleChunkUploadSessions() (removed int, err error) {
|
||||||
|
root := chunkSessionsRoot()
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
maxAge, _ := loadChunkCleanupParameters()
|
||||||
|
now := time.Now()
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
if !validUploadID(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
created := chunkSessionCreatedAt(name)
|
||||||
|
if created.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if now.Sub(created) < maxAge {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dir := filepath.Join(root, name)
|
||||||
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
logger.Err("chunk_upload", "删除过期分片目录失败 %s: %v", dir, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
return removed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartStaleChunkUploadSweep 启动后延迟执行一次,再按周期清扫非活动 .chunk-uploads
|
||||||
|
func StartStaleChunkUploadSweep(ctx context.Context) {
|
||||||
|
go func() {
|
||||||
|
const bootDelay = 2 * time.Minute
|
||||||
|
t := time.NewTimer(bootDelay)
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
case <-ctx.Done():
|
||||||
|
if !t.Stop() {
|
||||||
|
<-t.C
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
run := func() {
|
||||||
|
n, err := SweepStaleChunkUploadSessions()
|
||||||
|
if err != nil {
|
||||||
|
logger.Err("chunk_upload", "扫描 .chunk-uploads 失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
logger.Log("chunk_upload", "已删除 %d 个过期分片上传临时目录(超过后台或环境变量配置的保留时长)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run()
|
||||||
|
lastSweep := time.Now()
|
||||||
|
|
||||||
|
tick := time.NewTicker(time.Minute)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
_, interval := loadChunkCleanupParameters()
|
||||||
|
if time.Since(lastSweep) >= interval {
|
||||||
|
run()
|
||||||
|
lastSweep = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
182
server/handlers/yuheng_cloud_register.go
Normal file
182
server/handlers/yuheng_cloud_register.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"yh_web/server/config"
|
||||||
|
"yh_web/server/models"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
)
|
||||||
|
|
||||||
|
const yuhengCloudRegisterColl = "yuheng_cloud_register_records"
|
||||||
|
|
||||||
|
func cloudRegisterURL() string {
|
||||||
|
u := strings.TrimSpace(os.Getenv("YH_CLOUD_REGISTER_URL"))
|
||||||
|
if u != "" {
|
||||||
|
return strings.TrimSuffix(u, "/")
|
||||||
|
}
|
||||||
|
return "http://www.cloud.yuxindazhineng.com:3001/register"
|
||||||
|
}
|
||||||
|
|
||||||
|
// YuhengCloudRegisterInput 与云端 POST /register 一致;email 仅用于调用云端,不写入 Mongo
|
||||||
|
type YuhengCloudRegisterInput struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
Email string `json:"email" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cloudRegisterPayload struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func postCloudRegister(ctx context.Context, payload cloudRegisterPayload) (int, string, error) {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cloudRegisterURL(), bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
client := &http.Client{Timeout: 45 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
return resp.StatusCode, strings.TrimSpace(string(b)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateYuhengCloudRegister 调用云端注册接口,成功后在 Mongo 写入一条记录(仅 username、password)
|
||||||
|
func CreateYuhengCloudRegister(c *gin.Context) {
|
||||||
|
var in YuhengCloudRegisterInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写用户名、密码与邮箱(邮箱仅提交云端)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.Username = strings.TrimSpace(in.Username)
|
||||||
|
in.Password = strings.TrimSpace(in.Password)
|
||||||
|
in.Email = strings.TrimSpace(in.Email)
|
||||||
|
if in.Username == "" || in.Password == "" || in.Email == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名、密码、邮箱不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 50*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
status, bodySnippet, err := postCloudRegister(ctx, cloudRegisterPayload{
|
||||||
|
Username: in.Username,
|
||||||
|
Password: in.Password,
|
||||||
|
Email: in.Email,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "调用云端注册失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
msg := bodySnippet
|
||||||
|
if len(msg) > 500 {
|
||||||
|
msg = msg[:500] + "…"
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = http.StatusText(status)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("云端返回 %d: %s", status, msg)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := config.GetDB(config.DBName)
|
||||||
|
if db == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "数据库未连接,未写入本地记录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
insCtx, insCancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||||
|
defer insCancel()
|
||||||
|
doc := bson.M{
|
||||||
|
"username": in.Username,
|
||||||
|
"password": in.Password,
|
||||||
|
"created_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
res, err := db.Collection(yuhengCloudRegisterColl).InsertOne(insCtx, doc)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "云端已成功但本地记录失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idHex := ""
|
||||||
|
switch v := res.InsertedID.(type) {
|
||||||
|
case bson.ObjectID:
|
||||||
|
idHex = v.Hex()
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"id": idHex, "message": "已提交云端注册并写入本地记录"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListYuhengCloudRegisterRecords 分页列出本地留痕(便于管理页展示)
|
||||||
|
func ListYuhengCloudRegisterRecords(c *gin.Context) {
|
||||||
|
db := config.GetDB(config.DBName)
|
||||||
|
if db == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"list": []any{}, "total": 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 || pageSize > 100 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
skip := int64((page - 1) * pageSize)
|
||||||
|
limit := int64(pageSize)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
coll := db.Collection(yuhengCloudRegisterColl)
|
||||||
|
total, err := coll.CountDocuments(ctx, bson.M{})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}).SetSkip(skip).SetLimit(limit)
|
||||||
|
cur, err := coll.Find(ctx, bson.M{}, opts)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer cur.Close(ctx)
|
||||||
|
var list []models.YuhengCloudRegisterRecord
|
||||||
|
for cur.Next(ctx) {
|
||||||
|
var row struct {
|
||||||
|
ID bson.ObjectID `bson:"_id"`
|
||||||
|
Username string `bson:"username"`
|
||||||
|
Password string `bson:"password"`
|
||||||
|
CreatedAt string `bson:"created_at"`
|
||||||
|
}
|
||||||
|
if err := cur.Decode(&row); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list = append(list, models.YuhengCloudRegisterRecord{
|
||||||
|
ID: row.ID.Hex(),
|
||||||
|
Username: row.Username,
|
||||||
|
Password: row.Password,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"list": list, "total": total})
|
||||||
|
}
|
||||||
@@ -145,6 +145,8 @@ func main() {
|
|||||||
c.JSON(http.StatusOK, structure)
|
c.JSON(http.StatusOK, structure)
|
||||||
})
|
})
|
||||||
admin.GET("/stats", handlers.GetStats)
|
admin.GET("/stats", handlers.GetStats)
|
||||||
|
admin.POST("/yuheng-cloud-accounts", handlers.RequirePermission(models.PermYuhengCloudManage), handlers.CreateYuhengCloudRegister)
|
||||||
|
admin.GET("/yuheng-cloud-accounts", handlers.RequirePermission(models.PermYuhengCloudManage), handlers.ListYuhengCloudRegisterRecords)
|
||||||
admin.GET("/live/moderation", handlers.RequirePermission(models.PermHomepageEdit), weblive.GetLiveModeration)
|
admin.GET("/live/moderation", handlers.RequirePermission(models.PermHomepageEdit), weblive.GetLiveModeration)
|
||||||
admin.PUT("/live/moderation/mute-all", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteAll)
|
admin.PUT("/live/moderation/mute-all", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteAll)
|
||||||
admin.PUT("/live/moderation/mute-ip", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteIP)
|
admin.PUT("/live/moderation/mute-ip", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteIP)
|
||||||
@@ -170,6 +172,11 @@ func main() {
|
|||||||
admin.GET("/sites/:site_id/assets/downloadable", handlers.RequirePermission(models.PermHomepageEdit), handlers.ListDownloadableAssets)
|
admin.GET("/sites/:site_id/assets/downloadable", handlers.RequirePermission(models.PermHomepageEdit), handlers.ListDownloadableAssets)
|
||||||
admin.GET("/sites/:site_id/assets", handlers.RequirePermission(models.PermSiteManage), handlers.ListSiteAssets)
|
admin.GET("/sites/:site_id/assets", handlers.RequirePermission(models.PermSiteManage), handlers.ListSiteAssets)
|
||||||
admin.POST("/sites/:site_id/assets", handlers.RequirePermission(models.PermModuleUpload), handlers.UploadSiteAsset)
|
admin.POST("/sites/:site_id/assets", handlers.RequirePermission(models.PermModuleUpload), handlers.UploadSiteAsset)
|
||||||
|
admin.POST("/sites/:site_id/assets/init-multipart", handlers.RequirePermission(models.PermModuleUpload), handlers.InitMultipartUpload)
|
||||||
|
admin.GET("/sites/:site_id/assets/multipart/:upload_id/status", handlers.RequirePermission(models.PermModuleUpload), handlers.MultipartUploadStatus)
|
||||||
|
admin.PUT("/sites/:site_id/assets/multipart/:upload_id/chunk/:chunk_index", handlers.RequirePermission(models.PermModuleUpload), handlers.PutMultipartChunk)
|
||||||
|
admin.POST("/sites/:site_id/assets/multipart/:upload_id/complete", handlers.RequirePermission(models.PermModuleUpload), handlers.CompleteMultipartUpload)
|
||||||
|
admin.DELETE("/sites/:site_id/assets/multipart/:upload_id", handlers.RequirePermission(models.PermModuleUpload), handlers.AbortMultipartUpload)
|
||||||
admin.POST("/sites/:site_id/folders", handlers.RequirePermission(models.PermModuleUpload), handlers.CreateSiteFolder)
|
admin.POST("/sites/:site_id/folders", handlers.RequirePermission(models.PermModuleUpload), handlers.CreateSiteFolder)
|
||||||
admin.DELETE("/sites/:site_id/assets/:asset_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSiteAsset)
|
admin.DELETE("/sites/:site_id/assets/:asset_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSiteAsset)
|
||||||
admin.GET("/sites", handlers.RequirePermission(models.PermSiteManage), handlers.GetSites)
|
admin.GET("/sites", handlers.RequirePermission(models.PermSiteManage), handlers.GetSites)
|
||||||
@@ -179,6 +186,8 @@ func main() {
|
|||||||
admin.DELETE("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSite)
|
admin.DELETE("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSite)
|
||||||
admin.GET("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.GetOfficialSite)
|
admin.GET("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.GetOfficialSite)
|
||||||
admin.PUT("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.SetOfficialSite)
|
admin.PUT("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.SetOfficialSite)
|
||||||
|
admin.GET("/system/chunk-upload-cleanup", handlers.RequirePermission(models.PermSiteManage), handlers.GetChunkUploadCleanupConfig)
|
||||||
|
admin.PUT("/system/chunk-upload-cleanup", handlers.RequirePermission(models.PermSiteManage), handlers.UpdateChunkUploadCleanupConfig)
|
||||||
|
|
||||||
// 角色权限管理
|
// 角色权限管理
|
||||||
admin.GET("/role-permissions", handlers.RequirePermission(models.PermRolePermission), handlers.GetRolePermissionsList)
|
admin.GET("/role-permissions", handlers.RequirePermission(models.PermRolePermission), handlers.GetRolePermissionsList)
|
||||||
@@ -240,6 +249,8 @@ func main() {
|
|||||||
|
|
||||||
// 启动后扫描 uploads 下各站点 promotion 中遗留的 .mov 并补转码(与上传后异步转码逻辑一致)
|
// 启动后扫描 uploads 下各站点 promotion 中遗留的 .mov 并补转码(与上传后异步转码逻辑一致)
|
||||||
go handlers.SweepPromotionTranscodeOnStartup()
|
go handlers.SweepPromotionTranscodeOnStartup()
|
||||||
|
// 定期删除未合并、超过保留期的分片临时目录(.chunk-uploads)
|
||||||
|
go handlers.StartStaleChunkUploadSweep(context.Background())
|
||||||
|
|
||||||
r.Run(":" + port)
|
r.Run(":" + port)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const (
|
|||||||
PermSMSConfig = "sms_config"
|
PermSMSConfig = "sms_config"
|
||||||
PermPaymentConfig = "payment_config"
|
PermPaymentConfig = "payment_config"
|
||||||
PermRolePermission = "role:permission" // 角色权限管理
|
PermRolePermission = "role:permission" // 角色权限管理
|
||||||
|
PermYuhengCloudManage = "yuheng_cloud:manage" // 宇恒云账号(云端注册留痕)
|
||||||
)
|
)
|
||||||
|
|
||||||
// PermissionItem 单条权限定义(JSON 须用小写 key/name,供前端展示与勾选)
|
// PermissionItem 单条权限定义(JSON 须用小写 key/name,供前端展示与勾选)
|
||||||
@@ -32,6 +33,7 @@ var AllPermissions = []PermissionItem{
|
|||||||
{Key: PermSMSConfig, Name: "短信配置"},
|
{Key: PermSMSConfig, Name: "短信配置"},
|
||||||
{Key: PermPaymentConfig, Name: "支付配置"},
|
{Key: PermPaymentConfig, Name: "支付配置"},
|
||||||
{Key: PermRolePermission, Name: "角色权限管理"},
|
{Key: PermRolePermission, Name: "角色权限管理"},
|
||||||
|
{Key: PermYuhengCloudManage, Name: "宇恒云账号管理"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// RolePermissionsDoc MongoDB 文档:角色 ID -> 名称与权限列表(支持自定义角色)
|
// RolePermissionsDoc MongoDB 文档:角色 ID -> 名称与权限列表(支持自定义角色)
|
||||||
|
|||||||
9
server/models/yuheng_cloud_register.go
Normal file
9
server/models/yuheng_cloud_register.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// YuhengCloudRegisterRecord 宇恒云注册请求在本库的留痕(仅账号与密码;email 仅转发云端接口不落库)
|
||||||
|
type YuhengCloudRegisterRecord struct {
|
||||||
|
ID string `bson:"_id,omitempty" json:"id"`
|
||||||
|
Username string `bson:"username" json:"username"`
|
||||||
|
Password string `bson:"password" json:"password"`
|
||||||
|
CreatedAt string `bson:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ var requiredCollections = map[string][]indexSpec{
|
|||||||
"system_config": {},
|
"system_config": {},
|
||||||
"role_permissions": {{Keys: bson.D{{Key: "role_id", Value: 1}}, Name: "idx_role_id", Unique: true}},
|
"role_permissions": {{Keys: bson.D{{Key: "role_id", Value: 1}}, Name: "idx_role_id", Unique: true}},
|
||||||
"site_users": {{Keys: bson.D{{Key: "username", Value: 1}}, Name: "idx_username", Unique: true}},
|
"site_users": {{Keys: bson.D{{Key: "username", Value: 1}}, Name: "idx_username", Unique: true}},
|
||||||
|
"yuheng_cloud_register_records": {{Keys: bson.D{{Key: "created_at", Value: -1}}, Name: "idx_created_at"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
type indexSpec struct {
|
type indexSpec struct {
|
||||||
@@ -50,6 +51,7 @@ var tableDDL = map[string]string{
|
|||||||
"files": "CREATE TABLE IF NOT EXISTS \x60files\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60user_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名',\n \x60file_path\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '存储路径',\n \x60size\x60 BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_user_id\x60 (\x60user_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表';",
|
"files": "CREATE TABLE IF NOT EXISTS \x60files\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60user_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名',\n \x60file_path\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '存储路径',\n \x60size\x60 BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_user_id\x60 (\x60user_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表';",
|
||||||
"system_config": "CREATE TABLE IF NOT EXISTS \x60system_config\x60 (\n \x60id\x60 VARCHAR(64) NOT NULL COMMENT '配置键 payment, sms_platform 等',\n \x60payload\x60 JSON COMMENT '配置内容(支付/短信等)',\n \x60updated_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',\n PRIMARY KEY (\x60id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';",
|
"system_config": "CREATE TABLE IF NOT EXISTS \x60system_config\x60 (\n \x60id\x60 VARCHAR(64) NOT NULL COMMENT '配置键 payment, sms_platform 等',\n \x60payload\x60 JSON COMMENT '配置内容(支付/短信等)',\n \x60updated_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',\n PRIMARY KEY (\x60id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';",
|
||||||
"site_users": "CREATE TABLE IF NOT EXISTS \x60site_users\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60username\x60 VARCHAR(64) NOT NULL DEFAULT '' COMMENT '登录名',\n \x60password_hash\x60 VARCHAR(128) NOT NULL DEFAULT '' COMMENT '密码哈希',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间(UTC)',\n PRIMARY KEY (\x60id\x60),\n UNIQUE KEY \x60uk_username\x60 (\x60username\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='前台用户(弹幕)';",
|
"site_users": "CREATE TABLE IF NOT EXISTS \x60site_users\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60username\x60 VARCHAR(64) NOT NULL DEFAULT '' COMMENT '登录名',\n \x60password_hash\x60 VARCHAR(128) NOT NULL DEFAULT '' COMMENT '密码哈希',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间(UTC)',\n PRIMARY KEY (\x60id\x60),\n UNIQUE KEY \x60uk_username\x60 (\x60username\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='前台用户(弹幕)';",
|
||||||
|
"yuheng_cloud_register_records": "CREATE TABLE IF NOT EXISTS \x60yuheng_cloud_register_records\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60username\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '账号',\n \x60password\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '密码明文留痕',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_created_at\x60 (\x60created_at\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='宇恒云注册请求本地留痕';",
|
||||||
}
|
}
|
||||||
|
|
||||||
// CollectionInfo 线上集合信息(名称、文档数、索引)
|
// CollectionInfo 线上集合信息(名称、文档数、索引)
|
||||||
|
|||||||
Reference in New Issue
Block a user